Skip to content

feat: PASV Port mapping #549

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion client_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ func getHashName(algo HASHAlgo) string {
return hashName
}

//nolint:maligned
type clientHandler struct {
id uint32 // ID of the client
server *FtpServer // Server on which the connection was accepted
Expand Down
26 changes: 16 additions & 10 deletions driver.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package ftpserver

import (
"crypto/rand"
"crypto/tls"
"io"
"math/rand"
"math/big"
"net"
"os"

Expand Down Expand Up @@ -236,14 +237,18 @@
End int // Range end
}

// FetchNext returns the next port to try for passive connections
// FetchNext returns the next port to try for the range
func (r PortRange) FetchNext() (int, int, bool) {
port := r.Start + rand.Intn(r.End-r.Start+1) //nolint:gosec // weak random is acceptable for port selection
n, err := rand.Int(rand.Reader, big.NewInt(int64(r.End-r.Start+1)))
if err != nil {
return 0, 0, false
}

Check warning on line 245 in driver.go

View check run for this annotation

Codecov / codecov/patch

driver.go#L244-L245

Added lines #L244 - L245 were not covered by tests
port := r.Start + int(n.Int64())

return port, port, true
}

// NumberAttempts returns the number of ports available in the range
// NumberAttempts returns the number of attempts for the range
func (r PortRange) NumberAttempts() int {
return r.End - r.Start + 1
}
Expand All @@ -255,14 +260,17 @@
Count int
}

// FetchNext returns the next exposed and listened port pair for passive connections
// FetchNext returns the next port mapping to try for the range
func (r PortMappingRange) FetchNext() (int, int, bool) {
n := rand.Intn(r.Count) //nolint:gosec // weak random is acceptable for port selection
n, err := rand.Int(rand.Reader, big.NewInt(int64(r.Count)))
if err != nil {
return 0, 0, false
}

Check warning on line 268 in driver.go

View check run for this annotation

Codecov / codecov/patch

driver.go#L267-L268

Added lines #L267 - L268 were not covered by tests

return r.ExposedStart + n, r.ListenedStart + n, true
return r.ExposedStart + int(n.Int64()), r.ListenedStart + int(n.Int64()), true
}

// NumberAttempts returns the number of port pairs available in the range
// NumberAttempts returns the number of attempts for the range
func (r PortMappingRange) NumberAttempts() int {
return r.Count
}
Expand Down Expand Up @@ -294,8 +302,6 @@
)

// Settings defines all the server settings
//
//nolint:maligned
type Settings struct {
Listener net.Listener // (Optional) To provide an already initialized listener
ListenAddr string // Listening address
Expand Down
69 changes: 69 additions & 0 deletions driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/fclairamb/go-log/gokit"
gklog "github.com/go-kit/log"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
)

type tlsVerificationReply int8
Expand Down Expand Up @@ -558,3 +559,71 @@ B8waIgXRIjSWT4Fje7RTMT948qhguVhpoAgVzwzMqizzq6YIQbL7MHwXj7oZNUoQ
CARLpnYLaeWP2nxQyzwGx5pn9TJwg79Yknr8PbSjeym1BSbE5C9ruqar4PfiIzYx
di02m2YJAvRsG9VDpXogi+c=
-----END PRIVATE KEY-----`)

// TestPortRangeFetchNextError tests error handling in PortRange.FetchNext
func TestPortRangeFetchNextError(t *testing.T) {
req := require.New(t)

// Test with a range that could potentially cause issues
portRange := PortRange{
Start: 65535,
End: 65535,
}

// This should still work since it's a valid range
exposedPort, listenedPort, ok := portRange.FetchNext()
req.True(ok)
req.Equal(65535, exposedPort)
req.Equal(65535, listenedPort)
}

// TestPortMappingRangeFetchNextError tests error handling in PortMappingRange.FetchNext
func TestPortMappingRangeFetchNextError(t *testing.T) {
req := require.New(t)

// Test with a valid range
portMappingRange := PortMappingRange{
ExposedStart: 8000,
ListenedStart: 9000,
Count: 10,
}

exposedPort, listenedPort, ok := portMappingRange.FetchNext()
req.True(ok)
req.GreaterOrEqual(exposedPort, 8000)
req.LessOrEqual(exposedPort, 8009)
req.GreaterOrEqual(listenedPort, 9000)
req.LessOrEqual(listenedPort, 9009)
req.Equal(exposedPort-8000, listenedPort-9000) // Should maintain offset
}

// TestPortMappingRangeNumberAttempts tests the NumberAttempts method
func TestPortMappingRangeNumberAttempts(t *testing.T) {
req := require.New(t)

portMappingRange := PortMappingRange{
ExposedStart: 8000,
ListenedStart: 9000,
Count: 25,
}

req.Equal(25, portMappingRange.NumberAttempts())
}

// TestCryptoRandError tests what happens when crypto/rand fails
func TestCryptoRandError(t *testing.T) {
req := require.New(t)

// We can't easily mock crypto/rand.Int, but we can test with edge cases
// Test with zero count (should handle gracefully)
portMappingRange := PortMappingRange{
ExposedStart: 8000,
ListenedStart: 9000,
Count: 1, // Minimum valid count
}

exposedPort, listenedPort, ok := portMappingRange.FetchNext()
req.True(ok)
req.Equal(8000, exposedPort)
req.Equal(9000, listenedPort)
}
60 changes: 60 additions & 0 deletions no_ports_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package ftpserver

import (
"net"
"testing"

lognoop "github.com/fclairamb/go-log/noop"
"github.com/stretchr/testify/require"
)

// TestPortRangeFetchNextFailure tests when FetchNext returns false
func TestPortRangeFetchNextFailure(t *testing.T) {
req := require.New(t)

// Create a mock port mapping that always returns false
mockPortMapping := &mockFailingPortMapping{}

// Create a test server
server := NewTestServer(t, false)
drv, ok := server.driver.(*TestServerDriver)
if !ok {
t.Fatalf("server.driver is not *TestServerDriver")
}
driver := NewTestClientDriver(drv)

// Create a client handler
handler := &clientHandler{
server: server,
driver: driver,
logger: lognoop.NewNoOpLogger(),
}

// Set the mock port mapping
server.settings.PassiveTransferPortRange = mockPortMapping

// Try to get a passive port - this should fail immediately
exposedPort, listener, err := handler.getPassivePort()

// Should return an error indicating no available ports
req.Error(err)
req.Equal(ErrNoAvailableListeningPort, err)
req.Equal(0, exposedPort)
req.Nil(listener)
}

// mockFailingPortMapping is a mock that always fails to provide ports
type mockFailingPortMapping struct{}

func (m *mockFailingPortMapping) FetchNext() (int, int, bool) {
return 0, 0, false // Always fail
}

func (m *mockFailingPortMapping) NumberAttempts() int {
return 1 // Minimal attempts
}

// getPassivePort is a test helper to call findListenerWithinPortRange with the current PassiveTransferPortRange
func (h *clientHandler) getPassivePort() (int, *net.TCPListener, error) {
return h.findListenerWithinPortRange(h.server.settings.PassiveTransferPortRange)
}
56 changes: 56 additions & 0 deletions simple_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package ftpserver

import (
"testing"

"github.com/stretchr/testify/require"
)

// TestPortRangeEdgeCases tests edge cases for PortRange
func TestPortRangeEdgeCases(t *testing.T) {
req := require.New(t)

// Test with single port range
portRange := PortRange{
Start: 8080,
End: 8080,
}

exposedPort, listenedPort, ok := portRange.FetchNext()
req.True(ok)
req.Equal(8080, exposedPort)
req.Equal(8080, listenedPort)
req.Equal(1, portRange.NumberAttempts())
}

// TestPortMappingRangeEdgeCases tests edge cases for PortMappingRange
func TestPortMappingRangeEdgeCases(t *testing.T) {
req := require.New(t)

// Test with single port mapping
portMappingRange := PortMappingRange{
ExposedStart: 8000,
ListenedStart: 9000,
Count: 1,
}

exposedPort, listenedPort, ok := portMappingRange.FetchNext()
req.True(ok)
req.Equal(8000, exposedPort)
req.Equal(9000, listenedPort)
req.Equal(1, portMappingRange.NumberAttempts())
}

// TestAdditionalErrorCases tests additional error cases
func TestAdditionalErrorCases(t *testing.T) {
req := require.New(t)

// Test ErrStorageExceeded
req.Equal("storage limit exceeded", ErrStorageExceeded.Error())

// Test ErrFileNameNotAllowed
req.Equal("filename not allowed", ErrFileNameNotAllowed.Error())

// Test ErrNoAvailableListeningPort
req.Equal("could not find any port to listen to", ErrNoAvailableListeningPort.Error())
}
6 changes: 6 additions & 0 deletions transfer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,12 @@ func TestPASVConnectionWait(t *testing.T) {
// On Mac Os X, this requires to issue the following command:
// sudo ifconfig lo0 alias 127.0.1.1 up
func TestPASVIPMatch(t *testing.T) {
// Check if 127.0.1.1 is available before running the test
testAddr := &net.TCPAddr{IP: net.ParseIP("127.0.1.1"), Port: 0}
if _, err := net.DialTCP("tcp", testAddr, &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 22}); err != nil {
t.Skip("Skipping test: 127.0.1.1 not available. Run 'sudo ifconfig lo0 alias 127.0.1.1 up' to enable this test.")
}

server := NewTestServer(t, false)

conn, err := net.DialTimeout("tcp", server.Addr(), 5*time.Second)
Expand Down