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
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:
90
weed/sftpd/auth/auth.go
Normal file
90
weed/sftpd/auth/auth.go
Normal 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)
|
||||
}
|
||||
79
weed/sftpd/auth/auth_test.go
Normal file
79
weed/sftpd/auth/auth_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
150
weed/sftpd/auth/certificate.go
Normal file
150
weed/sftpd/auth/certificate.go
Normal 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, ¬Found) {
|
||||
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
|
||||
}
|
||||
312
weed/sftpd/auth/certificate_test.go
Normal file
312
weed/sftpd/auth/certificate_test.go
Normal 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, ¬Found) {
|
||||
t.Fatalf("unexpected error type: %v", err)
|
||||
}
|
||||
}
|
||||
53
weed/sftpd/auth/password.go
Normal file
53
weed/sftpd/auth/password.go
Normal 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")
|
||||
}
|
||||
51
weed/sftpd/auth/publickey.go
Normal file
51
weed/sftpd/auth/publickey.go
Normal 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")
|
||||
}
|
||||
99
weed/sftpd/sftp_file_reader.go
Normal file
99
weed/sftpd/sftp_file_reader.go
Normal 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
|
||||
}
|
||||
89
weed/sftpd/sftp_file_writer.go
Normal file
89
weed/sftpd/sftp_file_writer.go
Normal 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
461
weed/sftpd/sftp_filer.go
Normal 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)
|
||||
}
|
||||
39
weed/sftpd/sftp_filer_test.go
Normal file
39
weed/sftpd/sftp_filer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
253
weed/sftpd/sftp_permissions.go
Normal file
253
weed/sftpd/sftp_permissions.go
Normal 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
|
||||
}
|
||||
64
weed/sftpd/sftp_permissions_test.go
Normal file
64
weed/sftpd/sftp_permissions_test.go
Normal 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
134
weed/sftpd/sftp_server.go
Normal 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
|
||||
}
|
||||
103
weed/sftpd/sftp_server_test.go
Normal file
103
weed/sftpd/sftp_server_test.go
Normal 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
327
weed/sftpd/sftp_service.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
276
weed/sftpd/user/filestore.go
Normal file
276
weed/sftpd/user/filestore.go
Normal 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()
|
||||
}
|
||||
36
weed/sftpd/user/filestore_test.go
Normal file
36
weed/sftpd/user/filestore_test.go
Normal 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
77
weed/sftpd/user/user.go
Normal 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
|
||||
}
|
||||
52
weed/sftpd/utils/lru_cache.go
Normal file
52
weed/sftpd/utils/lru_cache.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user