snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

90
weed/sftpd/auth/auth.go Normal file
View File

@@ -0,0 +1,90 @@
// Package auth provides authentication and authorization functionality for the SFTP server
package auth
import (
"fmt"
"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
certificateAuth *CertificateAuthenticator
enabledAuthMethods []string
}
// NewManager creates a new authentication manager.
//
// trustedUserCAKeysFile is the path to a file containing trusted CA public
// keys (OpenSSH authorized_keys format). It is required when "certificate"
// is listed in enabledAuthMethods and ignored otherwise.
func NewManager(userStore user.Store, enabledAuthMethods []string, trustedUserCAKeysFile string) (*Manager, error) {
manager := &Manager{
userStore: userStore,
enabledAuthMethods: enabledAuthMethods,
}
// Initialize authenticators based on enabled methods
passwordEnabled := false
publicKeyEnabled := false
certificateEnabled := false
for _, method := range enabledAuthMethods {
switch method {
case "password":
passwordEnabled = true
case "publickey":
publicKeyEnabled = true
case "certificate":
certificateEnabled = true
}
}
manager.passwordAuth = NewPasswordAuthenticator(userStore, passwordEnabled)
manager.publicKeyAuth = NewPublicKeyAuthenticator(userStore, publicKeyEnabled)
certAuth, err := NewCertificateAuthenticator(userStore, certificateEnabled, trustedUserCAKeysFile)
if err != nil {
return nil, fmt.Errorf("init certificate auth: %w", err)
}
manager.certificateAuth = certAuth
return manager, nil
}
// 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
}
// Wire the public-key channel. Certificate auth, when enabled, takes
// over the channel entirely (MinIO/OpenSSH-style): plain public keys
// are rejected even if "publickey" is also listed in enabledAuthMethods.
switch {
case m.certificateAuth.Enabled():
config.PublicKeyCallback = m.certificateAuth.Authenticate
case m.publicKeyAuth.Enabled():
config.PublicKeyCallback = m.publicKeyAuth.Authenticate
}
return config
}
// GetUser retrieves a user from the user store
func (m *Manager) GetUser(username string) (*user.User, error) {
return m.userStore.GetUser(username)
}

View File

@@ -0,0 +1,79 @@
package auth
import (
"strings"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
// When both "publickey" and "certificate" are enabled, certificate auth takes
// over the public-key channel: plain keys are rejected, certs are accepted, and
// password auth stays independently wired.
func TestManager_CertificateTakesOverPublicKeyChannel(t *testing.T) {
env := newTestEnv(t)
store := newStubStore("alice")
mgr, err := NewManager(store, []string{"password", "publickey", "certificate"}, env.caKeyFile)
if err != nil {
t.Fatalf("NewManager: %v", err)
}
cfg := mgr.GetSSHServerConfig()
if cfg.PasswordCallback == nil {
t.Fatal("expected password callback to stay wired")
}
if cfg.PublicKeyCallback == nil {
t.Fatal("expected public-key callback")
}
// Plain public key is rejected by the certificate authenticator.
if _, err := cfg.PublicKeyCallback(&fakeConnMetadata{user: "alice"}, env.userSigner.PublicKey()); err == nil {
t.Fatal("expected plain public key to be rejected")
} else if !strings.Contains(err.Error(), "public key without certificate") {
t.Fatalf("expected certificate-channel rejection, got %v", err)
}
// A valid cert is accepted.
cert := env.signCert(t, ssh.UserCert, []string{"alice"},
time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
perms, err := cfg.PublicKeyCallback(&fakeConnMetadata{user: "alice"}, cert)
if err != nil {
t.Fatalf("expected cert to be accepted: %v", err)
}
if perms.Extensions["username"] != "alice" {
t.Fatalf("expected username extension alice, got %q", perms.Extensions["username"])
}
}
// Without "certificate", the public-key channel keeps plain public-key auth.
func TestManager_PublicKeyOnlyChannel(t *testing.T) {
mgr, err := NewManager(newStubStore("alice"), []string{"publickey"}, "")
if err != nil {
t.Fatalf("NewManager: %v", err)
}
cfg := mgr.GetSSHServerConfig()
if cfg.PublicKeyCallback == nil {
t.Fatal("expected public-key callback")
}
env := newTestEnv(t)
// Routes to plain public-key auth (stub store has no registered key), which
// fails with a different error than the certificate channel would.
_, err = cfg.PublicKeyCallback(&fakeConnMetadata{user: "alice"}, env.userSigner.PublicKey())
if err == nil {
t.Fatal("expected authentication failure")
}
if strings.Contains(err.Error(), "without certificate") {
t.Fatalf("certificate auth should not be wired, got %v", err)
}
}
// Enabling certificate auth without a CA keys file is a hard configuration error.
func TestManager_CertificateRequiresCAFile(t *testing.T) {
if _, err := NewManager(newStubStore(), []string{"certificate"}, ""); err == nil {
t.Fatal("expected error when certificate auth enabled without CA keys file")
}
}

View File

@@ -0,0 +1,150 @@
package auth
import (
"bytes"
"crypto/subtle"
"errors"
"fmt"
"os"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"golang.org/x/crypto/ssh"
)
// CertificateAuthenticator authenticates clients that present an OpenSSH
// user certificate signed by one of the configured trusted CA public keys.
//
// Behaviour mirrors MinIO's --sftp=trusted-user-ca-key option and OpenSSH's
// TrustedUserCAKeys directive: when enabled, only key blobs of type
// *ssh.Certificate are accepted on the public-key channel. Plain public
// keys are rejected. The SSH login username must appear in the cert's
// ValidPrincipals list and must resolve to an existing user in the store.
type CertificateAuthenticator struct {
userStore user.Store
enabled bool
trustedCAs []ssh.PublicKey
checker *ssh.CertChecker
}
// NewCertificateAuthenticator constructs a CertificateAuthenticator.
// When enabled is true, caKeysFile must point to a file containing one or
// more CA public keys in OpenSSH authorized_keys format (one per line).
func NewCertificateAuthenticator(userStore user.Store, enabled bool, caKeysFile string) (*CertificateAuthenticator, error) {
a := &CertificateAuthenticator{
userStore: userStore,
enabled: enabled,
}
if !enabled {
return a, nil
}
if caKeysFile == "" {
return nil, fmt.Errorf("certificate auth enabled but no trustedUserCAKeysFile provided")
}
cas, err := loadAuthorizedKeysFile(caKeysFile)
if err != nil {
return nil, fmt.Errorf("load trusted user CA keys from %s: %w", caKeysFile, err)
}
if len(cas) == 0 {
return nil, fmt.Errorf("no trusted user CA keys found in %s", caKeysFile)
}
a.trustedCAs = cas
// Pre-marshal trusted CA keys once. IsUserAuthority runs on every
// authentication attempt, so caching the marshaled form avoids
// repeated allocations on the hot path.
trustedCAsMarshaled := make([][]byte, len(cas))
for i, ca := range cas {
trustedCAsMarshaled[i] = ca.Marshal()
}
a.checker = &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
marshaled := auth.Marshal()
for _, caBytes := range trustedCAsMarshaled {
if subtle.ConstantTimeCompare(marshaled, caBytes) == 1 {
return true
}
}
return false
},
}
return a, nil
}
// Enabled reports whether certificate authentication is active.
func (a *CertificateAuthenticator) Enabled() bool {
return a.enabled
}
// Authenticate implements ssh.ServerConfig.PublicKeyCallback.
func (a *CertificateAuthenticator) Authenticate(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
if !a.enabled {
return nil, fmt.Errorf("certificate authentication disabled")
}
cert, ok := key.(*ssh.Certificate)
if !ok {
return nil, fmt.Errorf("public key without certificate not allowed")
}
if cert.CertType != ssh.UserCert {
return nil, fmt.Errorf("certificate is not a user certificate")
}
if len(cert.ValidPrincipals) == 0 {
return nil, fmt.Errorf("certificate has no valid principals")
}
username := conn.User()
// CertChecker.Authenticate verifies the CA signature (via IsUserAuthority),
// the ValidAfter/ValidBefore window, and that username is in ValidPrincipals.
perms, err := a.checker.Authenticate(conn, key)
if err != nil {
return nil, fmt.Errorf("certificate validation failed: %w", err)
}
// The SSH login user must exist in the SeaweedFS user store.
if _, err := a.userStore.GetUser(username); err != nil {
var notFound *user.UserNotFoundError
if errors.As(err, &notFound) {
return nil, fmt.Errorf("user %q not found", username)
}
return nil, fmt.Errorf("lookup user %q: %w", username, err)
}
if perms == nil {
perms = &ssh.Permissions{}
}
if perms.Extensions == nil {
perms.Extensions = map[string]string{}
}
perms.Extensions["username"] = username
return perms, nil
}
// loadAuthorizedKeysFile parses an authorized_keys-style file and returns
// all public keys found in it. Blank lines and comment lines starting with
// '#' are skipped.
func loadAuthorizedKeysFile(path string) ([]ssh.PublicKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var keys []ssh.PublicKey
for _, line := range bytes.Split(data, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 || line[0] == '#' {
continue
}
pub, _, _, _, err := ssh.ParseAuthorizedKey(line)
if err != nil {
return nil, err
}
keys = append(keys, pub)
}
return keys, nil
}

View File

@@ -0,0 +1,312 @@
package auth
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"golang.org/x/crypto/ssh"
)
// stubStore is a minimal user.Store for auth tests.
type stubStore struct {
users map[string]*user.User
}
func newStubStore(usernames ...string) *stubStore {
s := &stubStore{users: map[string]*user.User{}}
for _, name := range usernames {
s.users[name] = &user.User{Username: name}
}
return s
}
func (s *stubStore) GetUser(username string) (*user.User, error) {
if u, ok := s.users[username]; ok {
return u, nil
}
return nil, &user.UserNotFoundError{Username: username}
}
func (s *stubStore) ValidatePassword(string, []byte) bool { return false }
func (s *stubStore) ValidatePublicKey(string, string) bool { return false }
func (s *stubStore) GetUserPermissions(string, string) []string { return nil }
func (s *stubStore) SaveUser(*user.User) error { return nil }
func (s *stubStore) DeleteUser(string) error { return nil }
func (s *stubStore) ListUsers() ([]string, error) { return nil, nil }
// fakeConnMetadata satisfies ssh.ConnMetadata for the parts CertChecker uses.
type fakeConnMetadata struct {
user string
}
func (c *fakeConnMetadata) User() string { return c.user }
func (c *fakeConnMetadata) SessionID() []byte { return []byte("session") }
func (c *fakeConnMetadata) ClientVersion() []byte { return []byte("SSH-2.0-test") }
func (c *fakeConnMetadata) ServerVersion() []byte { return []byte("SSH-2.0-test") }
func (c *fakeConnMetadata) RemoteAddr() net.Addr { return &fakeAddr{} }
func (c *fakeConnMetadata) LocalAddr() net.Addr { return &fakeAddr{} }
type fakeAddr struct{}
func (fakeAddr) Network() string { return "tcp" }
func (fakeAddr) String() string { return "127.0.0.1:0" }
// testEnv bundles a CA, a user signer, and a temp dir for a single test.
type testEnv struct {
caSigner ssh.Signer
userSigner ssh.Signer
caKeyFile string
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
_, caPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 CA: %v", err)
}
caSigner, err := ssh.NewSignerFromKey(caPriv)
if err != nil {
t.Fatalf("ca signer: %v", err)
}
_, userPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 user: %v", err)
}
userSigner, err := ssh.NewSignerFromKey(userPriv)
if err != nil {
t.Fatalf("user signer: %v", err)
}
dir := t.TempDir()
caFile := filepath.Join(dir, "ca_user.pub")
if err := os.WriteFile(caFile, ssh.MarshalAuthorizedKey(caSigner.PublicKey()), 0o600); err != nil {
t.Fatalf("write ca file: %v", err)
}
return &testEnv{caSigner: caSigner, userSigner: userSigner, caKeyFile: caFile}
}
// signCert produces a user cert signed by the test CA.
func (e *testEnv) signCert(t *testing.T, certType uint32, principals []string, validAfter, validBefore time.Time) *ssh.Certificate {
t.Helper()
cert := &ssh.Certificate{
Key: e.userSigner.PublicKey(),
CertType: certType,
ValidPrincipals: principals,
ValidAfter: uint64(validAfter.Unix()),
ValidBefore: uint64(validBefore.Unix()),
}
if err := cert.SignCert(rand.Reader, e.caSigner); err != nil {
t.Fatalf("sign cert: %v", err)
}
return cert
}
func TestCertificateAuthenticator_GoldenPath(t *testing.T) {
env := newTestEnv(t)
store := newStubStore("alice")
a, err := NewCertificateAuthenticator(store, true, env.caKeyFile)
if err != nil {
t.Fatalf("new: %v", err)
}
cert := env.signCert(t, ssh.UserCert, []string{"alice"},
time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
perms, err := a.Authenticate(&fakeConnMetadata{user: "alice"}, cert)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if perms.Extensions["username"] != "alice" {
t.Fatalf("expected username extension alice, got %q", perms.Extensions["username"])
}
}
func TestCertificateAuthenticator_RejectsPlainPublicKey(t *testing.T) {
env := newTestEnv(t)
store := newStubStore("alice")
a, err := NewCertificateAuthenticator(store, true, env.caKeyFile)
if err != nil {
t.Fatalf("new: %v", err)
}
_, err = a.Authenticate(&fakeConnMetadata{user: "alice"}, env.userSigner.PublicKey())
if err == nil {
t.Fatal("expected rejection of plain public key, got nil")
}
if !strings.Contains(err.Error(), "public key without certificate") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCertificateAuthenticator_RejectsHostCert(t *testing.T) {
env := newTestEnv(t)
store := newStubStore("alice")
a, _ := NewCertificateAuthenticator(store, true, env.caKeyFile)
cert := env.signCert(t, ssh.HostCert, []string{"alice"},
time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
if _, err := a.Authenticate(&fakeConnMetadata{user: "alice"}, cert); err == nil {
t.Fatal("expected rejection of host cert, got nil")
}
}
func TestCertificateAuthenticator_RejectsEmptyPrincipals(t *testing.T) {
env := newTestEnv(t)
store := newStubStore("alice")
a, _ := NewCertificateAuthenticator(store, true, env.caKeyFile)
cert := env.signCert(t, ssh.UserCert, nil,
time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
_, err := a.Authenticate(&fakeConnMetadata{user: "alice"}, cert)
if err == nil || !strings.Contains(err.Error(), "no valid principals") {
t.Fatalf("expected empty-principals rejection, got %v", err)
}
}
func TestCertificateAuthenticator_RejectsWrongPrincipal(t *testing.T) {
env := newTestEnv(t)
store := newStubStore("alice", "bob")
a, _ := NewCertificateAuthenticator(store, true, env.caKeyFile)
cert := env.signCert(t, ssh.UserCert, []string{"bob"},
time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
if _, err := a.Authenticate(&fakeConnMetadata{user: "alice"}, cert); err == nil {
t.Fatal("expected wrong-principal rejection, got nil")
}
}
func TestCertificateAuthenticator_RejectsExpiredCert(t *testing.T) {
env := newTestEnv(t)
store := newStubStore("alice")
a, _ := NewCertificateAuthenticator(store, true, env.caKeyFile)
cert := env.signCert(t, ssh.UserCert, []string{"alice"},
time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour))
if _, err := a.Authenticate(&fakeConnMetadata{user: "alice"}, cert); err == nil {
t.Fatal("expected expired-cert rejection, got nil")
}
}
func TestCertificateAuthenticator_RejectsUnknownCA(t *testing.T) {
env := newTestEnv(t)
store := newStubStore("alice")
a, _ := NewCertificateAuthenticator(store, true, env.caKeyFile)
// Sign with a different CA.
_, otherCAPriv, _ := ed25519.GenerateKey(rand.Reader)
otherCASigner, _ := ssh.NewSignerFromKey(otherCAPriv)
cert := &ssh.Certificate{
Key: env.userSigner.PublicKey(),
CertType: ssh.UserCert,
ValidPrincipals: []string{"alice"},
ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()),
ValidBefore: uint64(time.Now().Add(time.Hour).Unix()),
}
if err := cert.SignCert(rand.Reader, otherCASigner); err != nil {
t.Fatalf("sign: %v", err)
}
if _, err := a.Authenticate(&fakeConnMetadata{user: "alice"}, cert); err == nil {
t.Fatal("expected unknown-CA rejection, got nil")
}
}
func TestCertificateAuthenticator_RejectsUnknownUser(t *testing.T) {
env := newTestEnv(t)
store := newStubStore() // no users
a, _ := NewCertificateAuthenticator(store, true, env.caKeyFile)
cert := env.signCert(t, ssh.UserCert, []string{"alice"},
time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
_, err := a.Authenticate(&fakeConnMetadata{user: "alice"}, cert)
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected user-not-found, got %v", err)
}
}
func TestCertificateAuthenticator_LoadsMultipleCAKeys(t *testing.T) {
env := newTestEnv(t)
// Append a second CA pubkey to the file.
_, ca2Priv, _ := ed25519.GenerateKey(rand.Reader)
ca2Signer, _ := ssh.NewSignerFromKey(ca2Priv)
f, err := os.OpenFile(env.caKeyFile, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatalf("open: %v", err)
}
if _, err := f.Write(ssh.MarshalAuthorizedKey(ca2Signer.PublicKey())); err != nil {
t.Fatalf("write: %v", err)
}
f.Close()
store := newStubStore("alice")
a, err := NewCertificateAuthenticator(store, true, env.caKeyFile)
if err != nil {
t.Fatalf("new: %v", err)
}
if len(a.trustedCAs) != 2 {
t.Fatalf("expected 2 CAs, got %d", len(a.trustedCAs))
}
// A cert signed by the second CA should be accepted.
cert := &ssh.Certificate{
Key: env.userSigner.PublicKey(),
CertType: ssh.UserCert,
ValidPrincipals: []string{"alice"},
ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()),
ValidBefore: uint64(time.Now().Add(time.Hour).Unix()),
}
if err := cert.SignCert(rand.Reader, ca2Signer); err != nil {
t.Fatalf("sign: %v", err)
}
if _, err := a.Authenticate(&fakeConnMetadata{user: "alice"}, cert); err != nil {
t.Fatalf("Authenticate with second CA: %v", err)
}
}
func TestNewCertificateAuthenticator_DisabledIgnoresFile(t *testing.T) {
a, err := NewCertificateAuthenticator(newStubStore(), false, "")
if err != nil {
t.Fatalf("new: %v", err)
}
if a.Enabled() {
t.Fatal("expected disabled")
}
}
func TestNewCertificateAuthenticator_EnabledRequiresFile(t *testing.T) {
_, err := NewCertificateAuthenticator(newStubStore(), true, "")
if err == nil {
t.Fatal("expected error when enabled without file")
}
}
func TestNewCertificateAuthenticator_MissingFile(t *testing.T) {
_, err := NewCertificateAuthenticator(newStubStore(), true, filepath.Join(t.TempDir(), "nope.pub"))
if err == nil {
t.Fatal("expected error for missing file")
}
// Sanity: not a not-found-user-style error.
var notFound *user.UserNotFoundError
if errors.As(err, &notFound) {
t.Fatalf("unexpected error type: %v", err)
}
}

View File

@@ -0,0 +1,53 @@
package auth
import (
"fmt"
"math/rand/v2"
"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")
}

View File

@@ -0,0 +1,51 @@
package auth
import (
"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 a.userStore.ValidatePublicKey(username, keyData) {
return &ssh.Permissions{
Extensions: map[string]string{
"username": username,
},
}, nil
}
return nil, fmt.Errorf("authentication failed")
}

View File

@@ -0,0 +1,99 @@
package sftpd
import (
"fmt"
"io"
"sync"
"github.com/seaweedfs/seaweedfs/weed/filer"
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/sftpd/utils"
)
type SeaweedFileReaderAt struct {
fs *SftpServer
entry *filer_pb.Entry
reader io.ReadSeeker
mu sync.Mutex
bufferSize int
cache *utils.LruCache
fileSize int64
}
func NewSeaweedFileReaderAt(fs *SftpServer, entry *filer_pb.Entry) *SeaweedFileReaderAt {
return &SeaweedFileReaderAt{
fs: fs,
entry: entry,
bufferSize: 5 * 1024 * 1024, // 5MB
cache: utils.NewLRUCache(10), // Max 10 chunks = ~50MB
fileSize: int64(entry.Attributes.FileSize),
}
}
func (ra *SeaweedFileReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
ra.mu.Lock()
defer ra.mu.Unlock()
if off >= ra.fileSize {
return 0, io.EOF
}
remaining := len(p)
readOffset := off
totalRead := 0
for remaining > 0 && readOffset < ra.fileSize {
bufferKey := (readOffset / int64(ra.bufferSize)) * int64(ra.bufferSize)
bufferOffset := int(readOffset - bufferKey)
buffer, ok := ra.cache.Get(bufferKey)
if !ok {
readSize := ra.bufferSize
if bufferKey+int64(readSize) > ra.fileSize {
readSize = int(ra.fileSize - bufferKey)
}
if ra.reader == nil {
r := filer.NewFileReader(ra.fs, ra.entry)
if rs, ok := r.(io.ReadSeeker); ok {
ra.reader = rs
} else {
return 0, fmt.Errorf("reader is not seekable")
}
}
if _, err := ra.reader.Seek(bufferKey, io.SeekStart); err != nil {
return 0, fmt.Errorf("seek error: %w", err)
}
buffer = make([]byte, readSize)
readBytes, err := io.ReadFull(ra.reader, buffer)
if err != nil && err != io.ErrUnexpectedEOF {
return 0, fmt.Errorf("read error: %w", err)
}
buffer = buffer[:readBytes]
ra.cache.Put(bufferKey, buffer)
}
toCopy := len(buffer) - bufferOffset
if toCopy > remaining {
toCopy = remaining
}
if toCopy <= 0 {
break
}
copy(p[totalRead:], buffer[bufferOffset:bufferOffset+toCopy])
totalRead += toCopy
readOffset += int64(toCopy)
remaining -= toCopy
}
if totalRead == 0 {
return 0, io.EOF
}
if totalRead < len(p) {
return totalRead, io.EOF
}
return totalRead, nil
}

View File

@@ -0,0 +1,89 @@
// sftp_helpers.go
package sftpd
import (
"io"
"os"
"sync"
"time"
"github.com/pkg/sftp"
)
// 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
}
// 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
}
// SeaweedSftpFileWriter buffers writes and flushes on Close.
type SeaweedSftpFileWriter struct {
fs SftpServer
req *sftp.Request
absPath string // Absolute path after HomeDir translation
mu sync.Mutex
tmpFile *os.File
permissions os.FileMode
uid uint32
gid uint32
offset int64
}
func (w *SeaweedSftpFileWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
n, err := w.tmpFile.WriteAt(p, w.offset)
w.offset += int64(n)
return n, err
}
func (w *SeaweedSftpFileWriter) WriteAt(p []byte, off int64) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.tmpFile.WriteAt(p, off)
}
func (w *SeaweedSftpFileWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
defer os.Remove(w.tmpFile.Name()) // Clean up temp file
defer w.tmpFile.Close()
if _, err := w.tmpFile.Seek(0, io.SeekStart); err != nil {
return err
}
// Stream the file to the absolute path (after HomeDir translation)
return w.fs.putFile(w.absPath, w.tmpFile, w.fs.user)
}

461
weed/sftpd/sftp_filer.go Normal file
View File

@@ -0,0 +1,461 @@
// sftp_filer_refactored.go
package sftpd
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strings"
"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/security"
weed_server "github.com/seaweedfs/seaweedfs/weed/server"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"github.com/seaweedfs/seaweedfs/weed/util"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
"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 {
if isNotExistError(err) {
return os.ErrNotExist
}
return err
}
if r.Entry == nil {
return fmt.Errorf("%s not found in %s", name, dir)
}
entry = r.Entry
return nil
})
if err != nil {
if isNotExistError(err) {
return nil, os.ErrNotExist
}
return nil, fmt.Errorf("lookup %s: %w", p, err)
}
return entry, nil
}
func isNotExistError(err error) bool {
return strings.Contains(err.Error(), "not found") ||
strings.Contains(err.Error(), "no entry is found") ||
strings.Contains(err.Error(), "file does not exist") ||
err == os.ErrNotExist
}
// 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(context.Background(), 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 {
absPath, err := fs.toAbsolutePath(r.Filepath)
if err != nil {
return err
}
glog.V(1).Infof("Dispatch: %s %s (absolute: %s)", r.Method, r.Filepath, absPath)
switch r.Method {
case "Remove":
return fs.removeEntry(absPath)
case "Rename":
absTarget, err := fs.toAbsolutePath(r.Target)
if err != nil {
return err
}
return fs.renameEntry(absPath, absTarget)
case "Mkdir":
return fs.makeDir(absPath)
case "Rmdir":
return fs.removeDir(absPath)
case "Setstat":
return fs.setFileStatWithRequest(absPath, r)
default:
return fmt.Errorf("unsupported: %s", r.Method)
}
}
// ==================== File Operations ====================
func (fs *SftpServer) readFile(r *sftp.Request) (io.ReaderAt, error) {
absPath, err := fs.toAbsolutePath(r.Filepath)
if err != nil {
return nil, err
}
if err := fs.checkFilePermission(absPath, "read"); err != nil {
return nil, err
}
entry, err := fs.getEntry(absPath)
if err != nil {
return nil, err
}
return NewSeaweedFileReaderAt(fs, entry), nil
}
func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
absPath, err := fs.toAbsolutePath(r.Filepath)
if err != nil {
return nil, err
}
dir, _ := util.FullPath(absPath).DirAndName()
if err := fs.checkFilePermission(dir, "write"); err != nil {
glog.Errorf("Permission denied for %s", dir)
return nil, err
}
// Create a temporary file to buffer writes
tmpFile, err := os.CreateTemp("", "sftp-upload-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
return &SeaweedSftpFileWriter{
fs: *fs,
req: r,
absPath: absPath,
tmpFile: tmpFile,
permissions: 0644,
uid: fs.user.Uid,
gid: fs.user.Gid,
offset: 0,
}, nil
}
func (fs *SftpServer) removeEntry(absPath string) error {
return fs.deleteEntry(absPath, false)
}
func (fs *SftpServer) renameEntry(absPath, absTarget string) error {
if err := fs.checkFilePermission(absPath, "rename"); err != nil {
return err
}
targetDir, _ := util.FullPath(absTarget).DirAndName()
if err := fs.checkFilePermission(targetDir, "write"); err != nil {
return err
}
oldDir, oldName := util.FullPath(absPath).DirAndName()
newDir, newName := util.FullPath(absTarget).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) setFileStatWithRequest(absPath string, r *sftp.Request) error {
if err := fs.checkFilePermission(absPath, "write"); err != nil {
return err
}
entry, err := fs.getEntry(absPath)
if err != nil {
return err
}
dir, _ := util.FullPath(absPath).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) {
absPath, err := fs.toAbsolutePath(r.Filepath)
if err != nil {
return nil, err
}
if err := fs.checkFilePermission(absPath, "list"); err != nil {
return nil, err
}
if r.Method == "Stat" || r.Method == "Lstat" {
entry, err := fs.getEntry(absPath)
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(absPath)
}
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(absPath string) error {
if fs.user == nil {
return fmt.Errorf("cannot create directory: no user info")
}
dir, name := util.FullPath(absPath).DirAndName()
if err := fs.checkFilePermission(dir, "write"); err != nil {
return err
}
// default mode and ownership
err := filer_pb.Mkdir(context.Background(), fs, string(dir), name, func(entry *filer_pb.Entry) {
mode := uint32(0755 | os.ModeDir)
// Defensive check: all paths should be under HomeDir after toAbsolutePath translation
if absPath == fs.user.HomeDir || strings.HasPrefix(absPath, 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(absPath string) error {
return fs.deleteEntry(absPath, false)
}
func (fs *SftpServer) putFile(filepath string, reader io.Reader, user *user.User) error {
dir, filename := util.FullPath(filepath).DirAndName()
// Escape the path so a "?" in the filename cannot inject filer query commands like cp.from/mv.from.
uploadUrl := (&url.URL{Scheme: "http", Host: fs.filerAddr.ToHttpAddress(), Path: filepath}).String()
// Let the global HTTP client normalize the scheme to https:// when TLS is configured
normalizedUrl, err := util_http.NormalizeUrl(uploadUrl)
if err != nil {
return fmt.Errorf("normalize upload url %q: %w", uploadUrl, err)
}
uploadUrl = normalizedUrl
// Compute MD5 while uploading
hash := md5.New()
body := io.TeeReader(reader, hash)
// We can skip ContentLength if unknown (chunked transfer encoding)
req, err := http.NewRequest(http.MethodPut, uploadUrl, body)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
// Add JWT authorization if filer signing key is configured
if len(fs.filerSigningKey) > 0 {
jwt := security.GenJwtForFilerServer(security.SigningKey(fs.filerSigningKey), fs.filerSigningExpiresAfter)
if jwt != "" {
req.Header.Set("Authorization", security.BearerPrefix+string(jwt))
}
}
resp, err := util_http.Do(req)
if err != nil {
return fmt.Errorf("upload to filer: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", 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: %w", 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: %w", 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
}
// ==================== 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
}
// FileStat represents file statistics in a platform-independent way
type FileStat struct {
Uid uint32
Gid uint32
}
func (fi *EnhancedFileInfo) Sys() interface{} {
return &FileStat{Uid: fi.uid, Gid: fi.gid}
}
func (fi *EnhancedFileInfo) Owner() (uid, gid int) {
return int(fi.uid), int(fi.gid)
}
func (fs *SftpServer) checkFilePermission(filepath string, permissions string) error {
return fs.CheckFilePermission(filepath, permissions)
}

View File

@@ -0,0 +1,39 @@
package sftpd
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb"
)
// TestPutFileEscapesQueryInjection ensures a filename containing "?" cannot be
// reinterpreted by the filer as a query string that injects cp.from/mv.from
// commands, which would let an SFTP user escape their home directory.
func TestPutFileEscapesQueryInjection(t *testing.T) {
var gotPath, gotRawQuery string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotRawQuery = r.URL.RawQuery
if _, err := w.Write([]byte(`{}`)); err != nil {
t.Errorf("write response: %v", err)
}
}))
defer ts.Close()
fs := &SftpServer{filerAddr: pb.ServerAddress(strings.TrimPrefix(ts.URL, "http://"))}
malicious := "/home/alice/steal?cp.from=/home/bob/secret.txt"
if err := fs.putFile(malicious, strings.NewReader("dummy"), nil); err != nil {
t.Fatalf("putFile: %v", err)
}
if gotRawQuery != "" {
t.Errorf("filename leaked into query string: RawQuery=%q", gotRawQuery)
}
if gotPath != malicious {
t.Errorf("path not delivered literally: got %q, want %q", gotPath, malicious)
}
}

View File

@@ -0,0 +1,253 @@
package sftpd
import (
"fmt"
"os"
stdpath "path"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"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"
)
// 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
// 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 (fs *SftpServer) CheckFilePermission(path string, perm string) error {
if fs.user == nil {
glog.V(0).Infof("permission denied. No user associated with the SftpServer.")
return os.ErrPermission
}
// Special case for "create" or "write" permissions on non-existent paths
// Check parent directory permissions instead
entry, err := fs.getEntry(path)
if err != nil {
// If the path doesn't exist and we're checking for create/write/mkdir permission,
// check permissions on the parent directory instead
if err == os.ErrNotExist {
parentPath := stdpath.Dir(path)
// Check if user can write to the parent directory
return fs.CheckFilePermission(parentPath, perm)
}
return fmt.Errorf("failed to get entry for path %s: %w", path, err)
}
// Rest of the function remains the same...
// Handle symlinks by resolving them
if entry.Attributes.GetSymlinkTarget() != "" {
// Get the actual entry for the resolved path
entry, err = fs.getEntry(entry.Attributes.GetSymlinkTarget())
if err != nil {
return fmt.Errorf("failed to get entry for resolved path %s: %w", entry.Attributes.SymlinkTarget, err)
}
}
// Special case: root user always has permission
if fs.user.Username == "root" || fs.user.Uid == 0 {
return nil
}
// Check if path is within user's home directory and has explicit permissions
if isPathInHomeDirectory(fs.user, path) {
// Check if user has explicit permissions for this path
if HasExplicitPermission(fs.user, path, perm, entry.IsDirectory) {
return nil
}
} else {
// For paths outside home directory or without explicit home permissions,
// check UNIX-style perms first
isOwner := fs.user.Uid == entry.Attributes.Uid
isGroup := fs.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(fs.user, path, perm, entry.IsDirectory) {
return nil
}
}
glog.V(0).Infof("permission denied for user %s on path %s for permission %s", fs.user.Username, path, perm)
return os.ErrPermission
}
// pathWithin reports whether candidate is base itself or a descendant of base,
// on path-component boundaries so /a does not match /a-sibling.
func pathWithin(base, candidate string) bool {
base = stdpath.Clean(base)
candidate = stdpath.Clean(candidate)
if base == "/" {
return strings.HasPrefix(candidate, "/")
}
return candidate == base || strings.HasPrefix(candidate, base+"/")
}
// isPathInHomeDirectory checks if a path is in the user's home directory
func isPathInHomeDirectory(user *user.User, path string) bool {
if user.HomeDir == "" {
return true
}
return pathWithin(user.HomeDir, path)
}
// 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 {
// The path must be the permission path exactly or under that path
cleaned := stdpath.Clean(p)
if pathWithin(cleaned, filepath) && len(cleaned) > len(bestMatch) {
bestMatch = cleaned
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
}

View File

@@ -0,0 +1,64 @@
package sftpd
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
)
func TestPathWithin(t *testing.T) {
tests := []struct {
base string
candidate string
want bool
}{
{"/tenants/alice", "/tenants/alice", true},
{"/tenants/alice", "/tenants/alice/file.txt", true},
{"/tenants/alice", "/tenants/alice/sub/dir", true},
{"/tenants/alice", "/tenants/alice-archive", false},
{"/tenants/alice", "/tenants/alice-archive/secret.txt", false},
{"/tenants/alice", "/tenants/alice2", false},
{"/tenants/alice", "/tenants", false},
{"/tenants/alice/", "/tenants/alice/file.txt", true},
{"/tenants/alice", "/tenants/alice/../alice-archive", false},
{"/tenants/alice", "/tenants/alice/..", false},
{"/", "/", true},
{"/", "/anything", true},
}
for _, tt := range tests {
if got := pathWithin(tt.base, tt.candidate); got != tt.want {
t.Errorf("pathWithin(%q, %q) = %v, want %v", tt.base, tt.candidate, got, tt.want)
}
}
}
func TestHasExplicitPermissionSiblingPrefix(t *testing.T) {
u := &user.User{
Username: "scoped",
HomeDir: "/",
Permissions: map[string][]string{
"/tenants/alice": {"read", "write", "list"},
},
}
if !HasExplicitPermission(u, "/tenants/alice/file.txt", PermRead, false) {
t.Error("expected read permission under /tenants/alice")
}
if HasExplicitPermission(u, "/tenants/alice-archive/secret.txt", PermRead, false) {
t.Error("ACL for /tenants/alice must not grant read on sibling /tenants/alice-archive")
}
if HasExplicitPermission(u, "/tenants/alice-archive/secret.txt", PermWrite, false) {
t.Error("ACL for /tenants/alice must not grant write on sibling /tenants/alice-archive")
}
}
func TestIsPathInHomeDirectorySiblingPrefix(t *testing.T) {
u := &user.User{Username: "alice", HomeDir: "/tenants/alice"}
if !isPathInHomeDirectory(u, "/tenants/alice/file.txt") {
t.Error("expected /tenants/alice/file.txt to be within home /tenants/alice")
}
if isPathInHomeDirectory(u, "/tenants/alice-archive/secret.txt") {
t.Error("home /tenants/alice must not match sibling /tenants/alice-archive")
}
}

134
weed/sftpd/sftp_server.go Normal file
View File

@@ -0,0 +1,134 @@
// sftp_server.go
package sftpd
import (
"context"
"fmt"
"io"
"os"
"path"
"strings"
"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/user"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
)
type SftpServer struct {
filerAddr pb.ServerAddress
grpcDialOption grpc.DialOption
dataCenter string
filerGroup string
user *user.User
filerSigningKey []byte
filerSigningExpiresAfter int
}
// NewSftpServer constructs the server.
func NewSftpServer(filerAddr pb.ServerAddress, grpcDialOption grpc.DialOption, dataCenter, filerGroup string, user *user.User, filerSigningKey []byte, filerSigningExpiresAfter int) SftpServer {
return SftpServer{
filerAddr: filerAddr,
grpcDialOption: grpcDialOption,
dataCenter: dataCenter,
filerGroup: filerGroup,
user: user,
filerSigningKey: filerSigningKey,
filerSigningExpiresAfter: filerSigningExpiresAfter,
}
}
// toAbsolutePath translates a user-relative path to an absolute filer path.
// When a user has HomeDir="/sftp/user", their view of "/" maps to "/sftp/user".
// This implements chroot-like behavior where the user's home directory
// becomes their root.
func (fs *SftpServer) toAbsolutePath(userPath string) (string, error) {
// If user has root as home directory, no translation needed
if fs.user.HomeDir == "" || fs.user.HomeDir == "/" {
return path.Clean(userPath), nil
}
// Concatenate home directory with user path, then clean to resolve any ".." components
p := path.Join(fs.user.HomeDir, strings.TrimPrefix(userPath, "/"))
// Security check: ensure the final path is within the home directory.
// This prevents path traversal attacks like `../..` that could escape the chroot jail.
if !strings.HasPrefix(p, fs.user.HomeDir+"/") && p != fs.user.HomeDir {
return "", fmt.Errorf("path traversal attempt: %s resolves to %s which is outside home dir %s", userPath, p, fs.user.HomeDir)
}
return p, nil
}
// 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)
}
// EnsureHomeDirectory creates the user's home directory if it doesn't exist
func (fs *SftpServer) EnsureHomeDirectory() error {
if fs.user.HomeDir == "" {
return fmt.Errorf("user has no home directory configured")
}
glog.V(0).Infof("Ensuring home directory exists for user %s: %s", fs.user.Username, fs.user.HomeDir)
// Check if home directory already exists
entry, err := fs.getEntry(fs.user.HomeDir)
if err == nil && entry != nil {
// Directory exists, just ensure proper ownership
if entry.Attributes.Uid != fs.user.Uid || entry.Attributes.Gid != fs.user.Gid {
dir, _ := util.FullPath(fs.user.HomeDir).DirAndName()
entry.Attributes.Uid = fs.user.Uid
entry.Attributes.Gid = fs.user.Gid
return fs.updateEntry(dir, entry)
}
return nil
}
// Skip permission check for home directory creation
// This is a special case where we want to create the directory regardless
dir, name := util.FullPath(fs.user.HomeDir).DirAndName()
// Create the directory with proper permissions using filer_pb.Mkdir
err = filer_pb.Mkdir(context.Background(), fs, dir, name, func(entry *filer_pb.Entry) {
mode := uint32(0700 | os.ModeDir) // Default to private permissions for home dirs
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)
})
if err != nil {
return fmt.Errorf("failed to create home directory: %w", err)
}
glog.V(0).Infof("Successfully created home directory for user %s: %s", fs.user.Username, fs.user.HomeDir)
return nil
}

View File

@@ -0,0 +1,103 @@
package sftpd
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"github.com/stretchr/testify/assert"
)
func stringPtr(s string) *string {
return &s
}
func TestToAbsolutePath(t *testing.T) {
tests := []struct {
name string
homeDir *string // Use pointer to distinguish between unset and empty
userPath string
expected string
expectError bool
}{
{
name: "normal path",
userPath: "/foo.txt",
expected: "/sftp/testuser/foo.txt",
},
{
name: "root path",
userPath: "/",
expected: "/sftp/testuser",
},
{
name: "path with dot",
userPath: "/./foo.txt",
expected: "/sftp/testuser/foo.txt",
},
{
name: "path traversal attempts",
userPath: "/../foo.txt",
expectError: true,
},
{
name: "path traversal attempts 2",
userPath: "../../foo.txt",
expectError: true,
},
{
name: "path traversal attempts 3",
userPath: "/subdir/../../foo.txt",
expectError: true,
},
{
name: "empty path",
userPath: "",
expected: "/sftp/testuser",
},
{
name: "multiple slashes",
userPath: "//foo.txt",
expected: "/sftp/testuser/foo.txt",
},
{
name: "trailing slash",
userPath: "/foo/",
expected: "/sftp/testuser/foo",
},
{
name: "empty HomeDir passthrough",
homeDir: stringPtr(""),
userPath: "/foo.txt",
expected: "/foo.txt",
},
{
name: "root HomeDir passthrough",
homeDir: stringPtr("/"),
userPath: "/foo.txt",
expected: "/foo.txt",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
homeDir := "/sftp/testuser" // default
if tt.homeDir != nil {
homeDir = *tt.homeDir
}
fs := &SftpServer{
user: &user.User{
HomeDir: homeDir,
},
}
got, err := fs.toAbsolutePath(tt.userPath)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, got)
}
})
}
}

327
weed/sftpd/sftp_service.go Normal file
View File

@@ -0,0 +1,327 @@
// sftp_service.go
package sftpd
import (
"context"
"fmt"
"io"
"net"
"os"
"path/filepath"
"time"
"github.com/pkg/sftp"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/sftpd/auth"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"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
}
// 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", "certificate"
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
// Certificate Authentication
TrustedUserCAKeysFile string // Path to file with trusted user CA public keys (OpenSSH authorized_keys format)
// JWT Configuration for Filer
FilerSigningKey []byte // JWT signing key for filer uploads
FilerSigningExpiresAfter int // JWT token expiration time in seconds
}
// 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 auth manager
authManager, err := auth.NewManager(userStore, options.AuthMethods, options.TrustedUserCAKeysFile)
if err != nil {
glog.Fatalf("Failed to initialize auth manager: %v", err)
}
service.authManager = authManager
return &service
}
// 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: %w", 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: %w", 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: %w", 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
glog.V(0).Info(fmt.Sprintf("Failed to add host key %s: %v", keyPath, err))
continue
}
hostKeysAdded++
}
if hostKeysAdded == 0 {
glog.V(0).Info(fmt.Sprintf("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,
s.options.FilerSigningKey,
s.options.FilerSigningExpiresAfter,
)
// Ensure home directory exists with proper permissions
if err := userFs.EnsureHomeDirectory(); 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) {
// Start at virtual root "/" - toAbsolutePath translates this to the user's HomeDir
serverOptions := sftp.WithStartDirectory("/")
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)
}
}
// Reload reloads the user store from disk, useful for HUP signal handling
func (s *SFTPService) Reload() {
glog.V(0).Info("Reload SFTP server...")
if fileStore, ok := s.userStore.(*user.FileStore); ok {
if err := fileStore.Reload(); err != nil {
glog.Errorf("Failed to reload user store: %v", err)
} else {
glog.V(0).Info("Successfully reloaded SFTP user store")
}
}
}

View File

@@ -0,0 +1,276 @@
package user
import (
"crypto/subtle"
"encoding/json"
"fmt"
"os"
"path"
"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
}
// 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)
}
// 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: %w", 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: %w", err)
}
var users []*User
if err := json.Unmarshal(data, &users); err != nil {
return fmt.Errorf("failed to parse user store file: %w", 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())
}
}
// Clean HomeDir to handle trailing slashes and normalize path
if user.HomeDir != "" {
user.HomeDir = path.Clean(user.HomeDir)
}
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: %w", err)
}
if err := os.WriteFile(s.filePath, data, 0600); err != nil {
return fmt.Errorf("failed to write user store file: %w", 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
}
// An empty stored or supplied password is never a match: a public-key-only
// user has no password, and equal-length zero slices would otherwise compare equal.
if len(user.Password) == 0 || len(password) == 0 {
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 subtle.ConstantTimeCompare([]byte(key), []byte(keyData)) == 1 {
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
}
// Reload reloads users from the file, useful for HUP signal handling
func (s *FileStore) Reload() error {
return s.loadUsers()
}

View File

@@ -0,0 +1,36 @@
package user
import "testing"
func newTestStore(users ...*User) *FileStore {
s := &FileStore{users: make(map[string]*User)}
for _, u := range users {
s.users[u.Username] = u
}
return s
}
func TestValidatePasswordRejectsEmpty(t *testing.T) {
s := newTestStore(
&User{Username: "keyonly", Password: "", PublicKeys: []string{"ssh-ed25519 AAAA"}},
&User{Username: "haspass", Password: "s3cret"},
)
cases := []struct {
username string
password string
want bool
}{
{"keyonly", "", false}, // public-key-only user must not accept an empty password
{"keyonly", "wrong", false},
{"haspass", "", false}, // a real password is never matched by an empty one
{"haspass", "s3cret", true},
{"haspass", "wrong", false},
{"missing", "", false},
}
for _, c := range cases {
if got := s.ValidatePassword(c.username, []byte(c.password)); got != c.want {
t.Errorf("ValidatePassword(%q, %q) = %v, want %v", c.username, c.password, got, c.want)
}
}
}

77
weed/sftpd/user/user.go Normal file
View File

@@ -0,0 +1,77 @@
// Package user provides user management functionality for the SFTP server
package user
import (
"math/rand/v2"
"path"
)
// 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
}
// 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: path.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
}

View File

@@ -0,0 +1,52 @@
package utils
import (
"container/list"
)
type CacheEntry struct {
key int64
value []byte
}
type LruCache struct {
capacity int
ll *list.List
cache map[int64]*list.Element
}
func NewLRUCache(capacity int) *LruCache {
return &LruCache{
capacity: capacity,
ll: list.New(),
cache: make(map[int64]*list.Element),
}
}
func (c *LruCache) Get(key int64) ([]byte, bool) {
if ele, ok := c.cache[key]; ok {
c.ll.MoveToFront(ele)
return ele.Value.(*CacheEntry).value, true
}
return nil, false
}
func (c *LruCache) Put(key int64, value []byte) {
if ele, ok := c.cache[key]; ok {
c.ll.MoveToFront(ele)
ele.Value.(*CacheEntry).value = value
return
}
if c.ll.Len() >= c.capacity {
oldest := c.ll.Back()
if oldest != nil {
c.ll.Remove(oldest)
delete(c.cache, oldest.Value.(*CacheEntry).key)
}
}
entry := &CacheEntry{key, value}
ele := c.ll.PushFront(entry)
c.cache[key] = ele
}