├── .github ├── auto-merge.yml ├── renovate.json └── workflows │ └── build.yml ├── .gitignore ├── .golangci.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── README.md ├── asciiconverter.go ├── asciiconverter_test.go ├── client_handler.go ├── client_handler_test.go ├── consts.go ├── control_fallback.go ├── control_unix.go ├── control_windows.go ├── driver.go ├── driver_test.go ├── errors.go ├── errors_test.go ├── go.mod ├── go.sum ├── handle_auth.go ├── handle_auth_test.go ├── handle_dirs.go ├── handle_dirs_test.go ├── handle_files.go ├── handle_files_test.go ├── handle_misc.go ├── handle_misc_test.go ├── license.txt ├── server.go ├── server_test.go ├── transfer_active.go ├── transfer_active_test.go ├── transfer_pasv.go └── transfer_test.go /.github/auto-merge.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-auto-merge - https://github.com/bobvanderlinden/probot-auto-merge 2 | 3 | updateBranch: true 4 | deleteBranchAfterMerge: true 5 | mergeMethod: squash 6 | maxRequestedChanges: 7 | NONE: 0 8 | blockingLabels: 9 | - blocked 10 | blockingTitleRegex: '\bWIP\b' 11 | rules: 12 | - minApprovals: 13 | CONTRIBUTOR: 2 14 | - requiredLabels: 15 | - automerge 16 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "updateTypes": ["minor", "patch", "pin", "digest"], 9 | "automerge": true, 10 | "labels": [ "automerge" ] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | # Controls when the action will run. Triggers the workflow on push or pull request 4 | # events but only for the master branch 5 | on: 6 | push: 7 | branches: 8 | - main 9 | - chore/lint* 10 | pull_request: 11 | 12 | permissions: 13 | # Required: allow read access to the content for analysis. 14 | contents: read 15 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 16 | pull-requests: read 17 | # Optional: Allow write access to checks to allow the action to annotate code in the PR. 18 | checks: write 19 | 20 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 21 | jobs: 22 | # This workflow contains a single job called "build" 23 | build: 24 | # The type of runner that the job will run on 25 | runs-on: ubuntu-24.04 26 | 27 | strategy: 28 | matrix: 29 | go: [ '1.23', '1.22' ] 30 | include: 31 | - go: '1.23' 32 | lint: true 33 | 34 | # Steps represent a sequence of tasks that will be executed as part of the job 35 | steps: 36 | # Checks-out your repository under $GITHUB_WORKSPACE 37 | - uses: actions/checkout@v4.2.2 38 | 39 | # Running golangci-lint 40 | - name: Linting 41 | if: matrix.lint 42 | uses: golangci/golangci-lint-action@v6.5.2 43 | with: 44 | version: v1.56.2 45 | only-new-issues: true 46 | 47 | # Install Go 48 | - name: Setup go 49 | uses: actions/setup-go@v5.5.0 50 | with: 51 | go-version: ${{ matrix.go }} 52 | 53 | - name: Build 54 | run: go build -v ./... 55 | 56 | - name: Test 57 | run: | 58 | go test -parallel 20 -v -race -coverprofile=coverage.txt -covermode=atomic ./... 59 | 60 | - name: Codecov 61 | uses: codecov/codecov-action@v5.4.3 62 | with: 63 | fail_ci_if_error: true # optional (default = false) 64 | env: 65 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 66 | 67 | # For github to have a unique status check name 68 | build-status: 69 | needs: build 70 | runs-on: ubuntu-24.04 71 | steps: 72 | - run: echo 'All good' 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ftpserver 3 | .idea 4 | .vscode 5 | debug 6 | __debug_bin* 7 | *.toml -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | # default concurrency is a available CPU number 3 | concurrency: 4 4 | 5 | # timeout for analysis, e.g. 30s, 5m, default is 1m 6 | timeout: 1m 7 | 8 | # exit code when at least one issue was found, default is 1 9 | issues-exit-code: 1 10 | 11 | # include test files or not, default is true 12 | tests: true 13 | 14 | # all available settings of specific linters 15 | linters-settings: 16 | errcheck: 17 | # report about not checking of errors in type assetions: `a := b.(MyStruct)`; 18 | # default is false: such cases aren't reported by default. 19 | check-type-assertions: false 20 | 21 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 22 | # default is false: such cases aren't reported by default. 23 | check-blank: false 24 | 25 | funlen: 26 | lines: 80 27 | statements: 40 28 | 29 | govet: 30 | # report about shadowed variables 31 | check-shadowing: true 32 | 33 | # settings per analyzer 34 | settings: 35 | printf: # analyzer name, run `go tool vet help` to see all analyzers 36 | funcs: # run `go tool vet help printf` to see available settings for `printf` analyzer 37 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof 38 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf 39 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf 40 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf 41 | 42 | # enable or disable analyzers by name 43 | enable: 44 | - atomicalign 45 | # - fieldalignment 46 | enable-all: false 47 | disable: 48 | - shadow 49 | disable-all: false 50 | golint: 51 | # minimal confidence for issues, default is 0.8 52 | min-confidence: 0 53 | gofmt: 54 | # simplify code: gofmt with `-s` option, true by default 55 | simplify: true 56 | goimports: 57 | # put imports beginning with prefix after 3rd-party packages; 58 | # it's a comma-separated list of prefixes 59 | local-prefixes: github.com/fclairamb/ftpserverlib 60 | gocyclo: 61 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 62 | min-complexity: 15 63 | gocognit: 64 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 65 | min-complexity: 30 66 | maligned: 67 | # print struct with more effective memory layout or not, false by default 68 | suggest-new: true 69 | dupl: 70 | # tokens count to trigger issue, 150 by default 71 | threshold: 100 72 | goconst: 73 | # minimal length of string constant, 3 by default 74 | min-len: 3 75 | # minimal occurrences count to trigger, 3 by default 76 | min-occurrences: 3 77 | misspell: 78 | # Correct spellings using locale preferences for US or UK. 79 | # Default is to use a neutral variety of English. 80 | # Setting locale to US will correct the British spelling of 'colour' to 'color'. 81 | locale: US 82 | ignore-words: 83 | - someword 84 | lll: 85 | # max line length, lines longer will be reported. Default is 120. 86 | # '\t' is counted as 1 character by default, and can be changed with the tab-width option 87 | line-length: 120 88 | # tab width in spaces. Default to 1. 89 | tab-width: 1 90 | unparam: 91 | # Inspect exported functions, default is false. Set to true if no external program/library imports your code. 92 | # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: 93 | # if it's called for subdir of a project it can't find external interfaces. All text editor integrations 94 | # with golangci-lint call it on a directory with the changed file. 95 | check-exported: false 96 | nakedret: 97 | # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 98 | max-func-lines: 30 99 | prealloc: 100 | # XXX: we don't recommend using this linter before doing performance profiling. 101 | # For most programs usage of prealloc will be a premature optimization. 102 | 103 | # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. 104 | # True by default. 105 | simple: true 106 | range-loops: true # Report preallocation suggestions on range loops, true by default 107 | for-loops: false # Report preallocation suggestions on for loops, false by default 108 | gocritic: 109 | # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks. 110 | # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". 111 | enabled-tags: 112 | - performance 113 | 114 | settings: # settings passed to gocritic 115 | captLocal: # must be valid enabled check name 116 | paramsOnly: true 117 | rangeValCopy: 118 | sizeThreshold: 32 119 | godox: 120 | # report any comments starting with keywords, this is useful for TODO or FIXME comments that 121 | # might be left in the code accidentally and should be resolved before merging 122 | keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting 123 | - NOTE 124 | - OPTIMIZE # marks code that should be optimized before merging 125 | - HACK # marks hack-arounds that should be removed before merging 126 | dogsled: 127 | # checks assignments with too many blank identifiers; default is 2 128 | max-blank-identifiers: 2 129 | 130 | whitespace: 131 | multi-if: false # Enforces newlines (or comments) after every multi-line if statement 132 | multi-func: false # Enforces newlines (or comments) after every multi-line function signature 133 | wsl: 134 | # If true append is only allowed to be cuddled if appending value is 135 | # matching variables, fields or types on line above. Default is true. 136 | strict-append: true 137 | # Allow calls and assignments to be cuddled as long as the lines have any 138 | # matching variables, fields or types. Default is true. 139 | allow-assign-and-call: true 140 | # Allow multiline assignments to be cuddled. Default is true. 141 | allow-multiline-assign: true 142 | # Allow declarations (var) to be cuddled. 143 | allow-cuddle-declarations: true 144 | # Allow trailing comments in ending of blocks 145 | allow-trailing-comment: false 146 | # Force newlines in end of case at this limit (0 = never). 147 | force-case-trailing-whitespace: 0 148 | depguard: 149 | rules: 150 | prevent_unmaintained_packages: 151 | list-mode: lax # allow unless explicitely denied 152 | files: 153 | - $all 154 | - "!$test" 155 | allow: 156 | - $gostd 157 | deny: 158 | - pkg: io/ioutil 159 | desc: "replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil" 160 | linters: 161 | disable-all: true 162 | enable: 163 | # https://golangci-lint.run/usage/linters/ 164 | - asciicheck 165 | - asasalint 166 | - bidichk 167 | - bodyclose 168 | # - deadcode -> unused 169 | - containedctx 170 | # - contextcheck -> creates an odd error here: https://github.com/fclairamb/ftpserverlib/blob/4d7c663e9e0b2650673fc2e0fcdb443895f2a1b9/server.go#L234 171 | # - copyloopvar -> unknown in v1.56.2 ??? 172 | # - cyclop -> Delaying it for now (too much work) 173 | - decorder 174 | - depguard 175 | - dogsled 176 | - dupl 177 | - dupword 178 | - durationcheck 179 | - errcheck 180 | - exhaustive 181 | - errchkjson 182 | - errname 183 | - errorlint 184 | - execinquery 185 | - exhaustive 186 | # - exhaustruct --> Not convinced it's useful 187 | # - exhaustivestruct 188 | - exportloopref 189 | - funlen 190 | - forbidigo 191 | - forcetypeassert 192 | - gci 193 | - ginkgolinter 194 | - gochecknoinits 195 | - gochecksumtype 196 | - gochecknoglobals 197 | - gocognit 198 | - goconst 199 | - gocritic 200 | - gocyclo 201 | # - godot --> lots of not so useful changes 202 | - godox 203 | - goerr113 204 | - gofmt 205 | # - gofumpt -> conflicts with wsl 206 | - goimports 207 | - gosimple 208 | # - golint --> revive 209 | - revive 210 | # - gomnd --> too much work 211 | # - gomoddirectives 212 | # - gomodguard 213 | - goprintffuncname 214 | - gosec 215 | - gosmopolitan 216 | - gosimple 217 | - govet 218 | - grouper 219 | - ineffassign 220 | - importas 221 | - inamedparam 222 | # - intrange --> upcoming 223 | # - interfacebloat 224 | # - interfacer --> (deprecated) 225 | # - ireturn --> I can't even see how to fix those like ClientHandler::getFileHandle 226 | - lll 227 | - loggercheck 228 | - maintidx 229 | - makezero 230 | - mirror 231 | # - maligned --> govet:fieldalignment 232 | - megacheck 233 | - misspell 234 | - musttag 235 | - nakedret 236 | # - nestif 237 | - nlreturn 238 | - prealloc 239 | - nestif 240 | - nilerr 241 | - nilnil 242 | - nolintlint 243 | - nlreturn 244 | - rowserrcheck 245 | - noctx 246 | - nonamedreturns 247 | # - scopelint --> exportloopref 248 | - nosprintfhostport 249 | - exportloopref 250 | - staticcheck 251 | # - structcheck -> unused 252 | - stylecheck 253 | # - paralleltest -> buggy, doesn't work with subtests 254 | - typecheck 255 | - perfsprint 256 | - prealloc 257 | - predeclared 258 | - reassign 259 | - promlinter 260 | - protogetter 261 | - rowserrcheck 262 | - sloglint 263 | - spancheck 264 | - sqlclosecheck 265 | - stylecheck 266 | - tagalign 267 | - tagliatelle 268 | - tenv 269 | - testableexamples 270 | - testifylint 271 | # - testpackage -> too late for that 272 | - thelper 273 | - tparallel 274 | - unconvert 275 | - unparam 276 | - unused 277 | - usestdlibvars 278 | - varnamelen 279 | - wastedassign 280 | # - varcheck -> unused 281 | - whitespace 282 | # - wrapcheck -> too much effort for now 283 | - wsl 284 | # - zerologlint -> Will most probably never use it 285 | fast: false 286 | 287 | issues: 288 | # Independently from option `exclude` we use default exclude patterns, 289 | # it can be disabled by this option. To list all 290 | # excluded by default patterns execute `golangci-lint run --help`. 291 | # Default value for this option is true. 292 | exclude-use-default: false 293 | 294 | exclude-rules: 295 | - path: _test\.go 296 | linters: 297 | - gochecknoglobals 298 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at florent@clairambault.fr. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 119 | 120 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 121 | enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | https://www.contributor-covenant.org/faq. Translations are available at 127 | https://www.contributor-covenant.org/translations. 128 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you want to contribute, just add an issue or submit a pull request. 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Golang FTP Server library 2 | 3 | [![Go version](https://img.shields.io/github/go-mod/go-version/fclairamb/ftpserverlib)](https://golang.org/doc/devel/release.html) 4 | [![Release](https://img.shields.io/github/v/release/fclairamb/ftpserverlib)](https://github.com/fclairamb/ftpserverlib/releases/latest) 5 | [![Build](https://github.com/fclairamb/ftpserverlib/workflows/Build/badge.svg)](https://github.com/fclairamb/ftpserverlib/actions/workflows/build.yml) 6 | [![codecov](https://codecov.io/gh/fclairamb/ftpserverlib/branch/main/graph/badge.svg?token=IVeoGgl1rj)](https://codecov.io/gh/fclairamb/ftpserverlib) 7 | [![Go Report Card](https://goreportcard.com/badge/fclairamb/ftpserverlib)](https://goreportcard.com/report/fclairamb/ftpserverlib) 8 | [![GoDoc](https://godoc.org/github.com/fclairamb/ftpserverlib?status.svg)](https://godoc.org/github.com/fclairamb/ftpserverlib) 9 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 10 | 11 | This library allows to easily build a simple and fully-featured FTP server using [afero](https://github.com/spf13/afero) as the backend filesystem. 12 | 13 | If you're interested in a fully featured FTP server, you should use [sftpgo](https://github.com/drakkan/sftpgo) (fully featured SFTP/FTP server) or [ftpserver](https://github.com/fclairamb/ftpserver) (basic FTP server). 14 | 15 | ## Current status of the project 16 | 17 | ### Features 18 | 19 | * Uploading and downloading files 20 | * Directory listing (LIST + MLST) 21 | * File and directory deletion and renaming 22 | * TLS support (AUTH + PROT) 23 | * File download/upload resume support (REST) 24 | * Passive socket connections (PASV and EPSV commands) 25 | * Active socket connections (PORT and EPRT commands) 26 | * IPv6 support (EPSV + EPRT) 27 | * Small memory footprint 28 | * Clean code: No sleep, no panic, no global sync (only around control/transfer connection per client) 29 | * Uses only the standard library except for: 30 | * [afero](https://github.com/spf13/afero) for generic file systems handling 31 | * [fclairamb/go-log](https://github.com/fclairamb/go-log) for logging through your existing libraries [go-kit/log](https://github.com/go-kit/log), [log15](https://github.com/inconshreveable/log15), [zap](https://github.com/uber-go/zap), [zerolog](https://github.com/rs/zerolog/), [logrus](https://github.com/sirupsen/logrus) 32 | * Supported extensions: 33 | * [AUTH](https://tools.ietf.org/html/rfc2228#page-6) - Control session protection 34 | * [AUTH TLS](https://tools.ietf.org/html/rfc4217#section-4.1) - TLS session 35 | * [PROT](https://tools.ietf.org/html/rfc2228#page-8) - Transfer protection 36 | * [EPRT/EPSV](https://tools.ietf.org/html/rfc2428) - IPv6 support 37 | * [MDTM](https://tools.ietf.org/html/rfc3659#page-8) - File Modification Time 38 | * [SIZE](https://tools.ietf.org/html/rfc3659#page-11) - Size of a file 39 | * [REST](https://tools.ietf.org/html/rfc3659#page-13) - Restart of interrupted transfer 40 | * [MLST](https://tools.ietf.org/html/rfc3659#page-23) - Simple file listing for machine processing 41 | * [MLSD](https://tools.ietf.org/html/rfc3659#page-23) - Directory listing for machine processing 42 | * [HASH](https://tools.ietf.org/html/draft-bryan-ftpext-hash-02) - Hashing of files 43 | * [AVLB](https://tools.ietf.org/html/draft-peterson-streamlined-ftp-command-extensions-10#section-4) - Available space 44 | * [COMB](https://help.globalscape.com/help/archive/eft6-4/mergedprojects/eft/allowingmultiparttransferscomb_command.htm) - Combine files 45 | 46 | ## Quick test 47 | The easiest way to test this library is to use [ftpserver](https://github.com/fclairamb/ftpserver). 48 | 49 | ## The driver 50 | The simplest way to get a good understanding of how the driver shall be implemented is to look at the [tests driver](https://github.com/fclairamb/ftpserverlib/blob/master/driver_test.go). 51 | 52 | ### The base API 53 | 54 | The API is directly based on [afero](https://github.com/spf13/afero). 55 | 56 | ```go 57 | // MainDriver handles the authentication and ClientHandlingDriver selection 58 | type MainDriver interface { 59 | // GetSettings returns some general settings around the server setup 60 | GetSettings() (*Settings, error) 61 | 62 | // ClientConnected is called to send the very first welcome message 63 | ClientConnected(cc ClientContext) (string, error) 64 | 65 | // ClientDisconnected is called when the user disconnects, even if he never authenticated 66 | ClientDisconnected(cc ClientContext) 67 | 68 | // AuthUser authenticates the user and selects an handling driver 69 | AuthUser(cc ClientContext, user, pass string) (ClientDriver, error) 70 | 71 | // GetTLSConfig returns a TLS Certificate to use 72 | // The certificate could frequently change if we use something like "let's encrypt" 73 | GetTLSConfig() (*tls.Config, error) 74 | } 75 | 76 | 77 | // ClientDriver is the base FS implementation that allows to manipulate files 78 | type ClientDriver interface { 79 | afero.Fs 80 | } 81 | 82 | // ClientContext is implemented on the server side to provide some access to few data around the client 83 | type ClientContext interface { 84 | // Path provides the path of the current connection 85 | Path() string 86 | 87 | // SetDebug activates the debugging of this connection commands 88 | SetDebug(debug bool) 89 | 90 | // Debug returns the current debugging status of this connection commands 91 | Debug() bool 92 | 93 | // Client's ID on the server 94 | ID() uint32 95 | 96 | // Client's address 97 | RemoteAddr() net.Addr 98 | 99 | // Servers's address 100 | LocalAddr() net.Addr 101 | 102 | // Client's version can be empty 103 | GetClientVersion() string 104 | 105 | // Close closes the connection and disconnects the client. 106 | Close() error 107 | 108 | // HasTLSForControl returns true if the control connection is over TLS 109 | HasTLSForControl() bool 110 | 111 | // HasTLSForTransfers returns true if the transfer connection is over TLS 112 | HasTLSForTransfers() bool 113 | 114 | // GetLastCommand returns the last received command 115 | GetLastCommand() string 116 | 117 | // GetLastDataChannel returns the last data channel mode 118 | GetLastDataChannel() DataChannel 119 | } 120 | 121 | // Settings define all the server settings 122 | type Settings struct { 123 | Listener net.Listener // (Optional) To provide an already initialized listener 124 | ListenAddr string // Listening address 125 | PublicHost string // Public IP to expose (only an IP address is accepted at this stage) 126 | PublicIPResolver PublicIPResolver // (Optional) To fetch a public IP lookup 127 | PassiveTransferPortRange *PortRange // (Optional) Port Range for data connections. Random if not specified 128 | ActiveTransferPortNon20 bool // Do not impose the port 20 for active data transfer (#88, RFC 1579) 129 | IdleTimeout int // Maximum inactivity time before disconnecting (#58) 130 | ConnectionTimeout int // Maximum time to establish passive or active transfer connections 131 | DisableMLSD bool // Disable MLSD support 132 | DisableMLST bool // Disable MLST support 133 | DisableMFMT bool // Disable MFMT support (modify file mtime) 134 | Banner string // Banner to use in server status response 135 | TLSRequired TLSRequirement // defines the TLS mode 136 | DisableLISTArgs bool // Disable ls like options (-a,-la etc.) for directory listing 137 | DisableSite bool // Disable SITE command 138 | DisableActiveMode bool // Disable Active FTP 139 | EnableHASH bool // Enable support for calculating hash value of files 140 | DisableSTAT bool // Disable Server STATUS, STAT on files and directories will still work 141 | DisableSYST bool // Disable SYST 142 | EnableCOMB bool // Enable COMB support 143 | DefaultTransferType TransferType // Transfer type to use if the client don't send the TYPE command 144 | // ActiveConnectionsCheck defines the security requirements for active connections 145 | ActiveConnectionsCheck DataConnectionRequirement 146 | // PasvConnectionsCheck defines the security requirements for passive connections 147 | PasvConnectionsCheck DataConnectionRequirement 148 | } 149 | ``` 150 | 151 | ### Extensions 152 | There are a few extensions to the base afero APIs so that you can perform some operations that aren't offered by afero. 153 | 154 | #### Pre-allocate some space 155 | ```go 156 | // ClientDriverExtensionAllocate is an extension to support the "ALLO" - file allocation - command 157 | type ClientDriverExtensionAllocate interface { 158 | 159 | // AllocateSpace reserves the space necessary to upload files 160 | AllocateSpace(size int) error 161 | } 162 | ``` 163 | 164 | #### Get available space 165 | ```go 166 | // ClientDriverExtensionAvailableSpace is an extension to implement to support 167 | // the AVBL ftp command 168 | type ClientDriverExtensionAvailableSpace interface { 169 | GetAvailableSpace(dirName string) (int64, error) 170 | } 171 | ``` 172 | 173 | #### Create symbolic link 174 | ```go 175 | // ClientDriverExtensionSymlink is an extension to support the "SITE SYMLINK" - symbolic link creation - command 176 | type ClientDriverExtensionSymlink interface { 177 | 178 | // Symlink creates a symlink 179 | Symlink(oldname, newname string) error 180 | 181 | // SymlinkIfPossible allows to get the source of a symlink (but we don't need for now) 182 | // ReadlinkIfPossible(name string) (string, error) 183 | } 184 | ``` 185 | 186 | #### Compute file hash 187 | ```go 188 | // ClientDriverExtensionHasher is an extension to implement if you want to handle file digests 189 | // yourself. You have to set EnableHASH to true for this extension to be called 190 | type ClientDriverExtensionHasher interface { 191 | ComputeHash(name string, algo HASHAlgo, startOffset, endOffset int64) (string, error) 192 | } 193 | ``` 194 | 195 | ## History of the project 196 | 197 | I wanted to make a system which would accept files through FTP and redirect them to something else. Go seemed like the obvious choice and it seemed there was a lot of libraries available but it turns out none of them were in a useable state. 198 | 199 | * [micahhausler/go-ftp](https://github.com/micahhausler/go-ftp) is a minimalistic implementation 200 | * [shenfeng/ftpd.go](https://github.com/shenfeng/ftpd.go) is very basic and 4 years old. 201 | * [yob/graval](https://github.com/yob/graval) is 3 years old and “experimental”. 202 | * [goftp/server](https://github.com/goftp/server) seemed OK but I couldn't use it on both Filezilla and the MacOs ftp client. 203 | * [andrewarrow/paradise_ftp](https://github.com/andrewarrow/paradise_ftp) - Was the only one of the list I could test right away. This is the project I forked from. 204 | -------------------------------------------------------------------------------- /asciiconverter.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | ) 7 | 8 | type convertMode int8 9 | 10 | const ( 11 | convertModeToCRLF convertMode = iota 12 | convertModeToLF 13 | 14 | bufferSize = 4096 15 | ) 16 | 17 | type asciiConverter struct { 18 | reader *bufio.Reader 19 | mode convertMode 20 | remaining []byte 21 | } 22 | 23 | func newASCIIConverter(r io.Reader, mode convertMode) *asciiConverter { 24 | reader := bufio.NewReaderSize(r, bufferSize) 25 | 26 | return &asciiConverter{ 27 | reader: reader, 28 | mode: mode, 29 | remaining: nil, 30 | } 31 | } 32 | 33 | func (c *asciiConverter) Read(bytes []byte) (int, error) { 34 | var data []byte 35 | var readBytes int 36 | var err error 37 | 38 | if len(c.remaining) > 0 { 39 | data = c.remaining 40 | c.remaining = nil 41 | } else { 42 | data, _, err = c.reader.ReadLine() 43 | if err != nil { 44 | return readBytes, err 45 | } 46 | } 47 | 48 | readBytes = len(data) 49 | if readBytes > 0 { 50 | maxSize := len(bytes) - 2 51 | if readBytes > maxSize { 52 | copy(bytes, data[:maxSize]) 53 | c.remaining = data[maxSize:] 54 | 55 | return maxSize, nil 56 | } 57 | 58 | copy(bytes[:readBytes], data[:readBytes]) 59 | } 60 | 61 | // we can have a partial read if the line is too long 62 | // or a trailing line without a line ending, so we check 63 | // the last byte to decide if we need to add a line ending. 64 | // This will also ensure that a file without line endings 65 | // will remain unchanged. 66 | // Please note that a binary file will likely contain 67 | // newline chars so it will be still corrupted if the 68 | // client transfers it in ASCII mode 69 | err = c.reader.UnreadByte() 70 | if err != nil { 71 | return readBytes, err 72 | } 73 | 74 | lastByte, err := c.reader.ReadByte() 75 | 76 | if err == nil && lastByte == '\n' { 77 | switch c.mode { 78 | case convertModeToCRLF: 79 | bytes[readBytes] = '\r' 80 | bytes[readBytes+1] = '\n' 81 | readBytes += 2 82 | case convertModeToLF: 83 | bytes[readBytes] = '\n' 84 | readBytes++ 85 | } 86 | } 87 | 88 | return readBytes, err //nolint:wrapcheck // here wrapping errors brings nothing 89 | } 90 | -------------------------------------------------------------------------------- /asciiconverter_test.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestASCIIConvert(t *testing.T) { 12 | lines := []byte("line1\r\nline2\r\n\r\nline4") 13 | src := bytes.NewBuffer(lines) 14 | dst := bytes.NewBuffer(nil) 15 | converter := newASCIIConverter(src, convertModeToLF) 16 | _, err := io.Copy(dst, converter) 17 | require.NoError(t, err) 18 | require.Equal(t, []byte("line1\nline2\n\nline4"), dst.Bytes()) 19 | 20 | lines = []byte("line1\nline2\n\nline4") 21 | dst = bytes.NewBuffer(nil) 22 | converter = newASCIIConverter(bytes.NewBuffer(lines), convertModeToCRLF) 23 | _, err = io.Copy(dst, converter) 24 | require.NoError(t, err) 25 | require.Equal(t, []byte("line1\r\nline2\r\n\r\nline4"), dst.Bytes()) 26 | 27 | // test a src buffers without line endings, it must remain unchanged 28 | buf := make([]byte, 131072) 29 | for j := range buf { 30 | buf[j] = 66 31 | } 32 | 33 | dst = bytes.NewBuffer(nil) 34 | converter = newASCIIConverter(bytes.NewBuffer(buf), convertModeToCRLF) 35 | _, err = io.Copy(dst, converter) 36 | require.NoError(t, err) 37 | require.Equal(t, buf, dst.Bytes()) 38 | } 39 | 40 | func BenchmarkASCIIConverter(b *testing.B) { 41 | linesCRLF := []byte("line1\r\nline2\r\n\r\nline4") 42 | linesLF := []byte("line1\nline2\n\nline4") 43 | 44 | readerCRLF := bytes.NewBuffer(nil) 45 | readerLF := bytes.NewBuffer(nil) 46 | 47 | for i := 0; i < 100000; i++ { 48 | _, err := readerCRLF.Write(linesCRLF) 49 | panicOnError(err) 50 | 51 | _, err = readerLF.Write(linesLF) 52 | panicOnError(err) 53 | } 54 | 55 | b.ResetTimer() 56 | 57 | for i := 0; i < b.N; i++ { 58 | c := newASCIIConverter(readerCRLF, convertModeToLF) 59 | _, err := io.Copy(io.Discard, c) 60 | panicOnError(err) 61 | 62 | c = newASCIIConverter(readerLF, convertModeToCRLF) 63 | _, err = io.Copy(io.Discard, c) 64 | panicOnError(err) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /client_handler.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | log "github.com/fclairamb/go-log" 14 | ) 15 | 16 | // HASHAlgo is the enumerable that represents the supported HASH algorithms. 17 | type HASHAlgo int8 18 | 19 | // Supported hash algorithms 20 | const ( 21 | HASHAlgoCRC32 HASHAlgo = iota 22 | HASHAlgoMD5 23 | HASHAlgoSHA1 24 | HASHAlgoSHA256 25 | HASHAlgoSHA512 26 | ) 27 | 28 | // TransferType is the enumerable that represents the supported transfer types. 29 | type TransferType int8 30 | 31 | // Supported transfer type 32 | const ( 33 | TransferTypeASCII TransferType = iota 34 | TransferTypeBinary 35 | ) 36 | 37 | // DataChannel is the enumerable that represents the data channel (active or passive) 38 | type DataChannel int8 39 | 40 | // Supported data channel types 41 | const ( 42 | DataChannelPassive DataChannel = iota + 1 43 | DataChannelActive 44 | ) 45 | 46 | const ( 47 | maxCommandSize = 4096 48 | ) 49 | 50 | var ( 51 | errNoTransferConnection = errors.New("unable to open transfer: no transfer connection") 52 | errTLSRequired = errors.New("unable to open transfer: TLS is required") 53 | errInvalidTLSRequirement = errors.New("invalid TLS requirement") 54 | ) 55 | 56 | func getHashMapping() map[string]HASHAlgo { 57 | mapping := make(map[string]HASHAlgo) 58 | mapping["CRC32"] = HASHAlgoCRC32 59 | mapping["MD5"] = HASHAlgoMD5 60 | mapping["SHA-1"] = HASHAlgoSHA1 61 | mapping["SHA-256"] = HASHAlgoSHA256 62 | mapping["SHA-512"] = HASHAlgoSHA512 63 | 64 | return mapping 65 | } 66 | 67 | func getHashName(algo HASHAlgo) string { 68 | hashName := "" 69 | hashMapping := getHashMapping() 70 | 71 | for k, v := range hashMapping { 72 | if v == algo { 73 | hashName = k 74 | } 75 | } 76 | 77 | return hashName 78 | } 79 | 80 | //nolint:maligned 81 | type clientHandler struct { 82 | id uint32 // ID of the client 83 | server *FtpServer // Server on which the connection was accepted 84 | driver ClientDriver // Client handling driver 85 | conn net.Conn // TCP connection 86 | writer *bufio.Writer // Writer on the TCP connection 87 | reader *bufio.Reader // Reader on the TCP connection 88 | user string // Authenticated user 89 | path string // Current path 90 | listPath string // Path for NLST/LIST requests 91 | clnt string // Identified client 92 | command string // Command received on the connection 93 | connectedAt time.Time // Date of connection 94 | ctxRnfr string // Rename from 95 | ctxRest int64 // Restart point 96 | debug bool // Show debugging info on the server side 97 | transferTLS bool // Use TLS for transfer connection 98 | controlTLS bool // Use TLS for control connection 99 | selectedHashAlgo HASHAlgo // algorithm used when we receive the HASH command 100 | logger log.Logger // Client handler logging 101 | currentTransferType TransferType // current transfer type 102 | transferWg sync.WaitGroup // wait group for command that open a transfer connection 103 | transferMu sync.Mutex // this mutex will protect the transfer parameters 104 | transfer transferHandler // Transfer connection (passive or active)s 105 | lastDataChannel DataChannel // Last data channel mode (passive or active) 106 | isTransferOpen bool // indicate if the transfer connection is opened 107 | isTransferAborted bool // indicate if the transfer was aborted 108 | tlsRequirement TLSRequirement // TLS requirement to respect 109 | extra any // Additional application-specific data 110 | paramsMutex sync.RWMutex // mutex to protect the parameters exposed to the library users 111 | } 112 | 113 | // newClientHandler initializes a client handler when someone connects 114 | func (server *FtpServer) newClientHandler( 115 | connection net.Conn, 116 | clientID uint32, 117 | transferType TransferType, 118 | ) *clientHandler { 119 | return &clientHandler{ 120 | server: server, 121 | conn: connection, 122 | id: clientID, 123 | writer: bufio.NewWriter(connection), 124 | reader: bufio.NewReaderSize(connection, maxCommandSize), 125 | connectedAt: time.Now().UTC(), 126 | path: "/", 127 | selectedHashAlgo: HASHAlgoSHA256, 128 | currentTransferType: transferType, 129 | logger: server.Logger.With("clientId", clientID), 130 | } 131 | } 132 | 133 | func (c *clientHandler) disconnect() { 134 | if err := c.conn.Close(); err != nil { 135 | c.logger.Warn( 136 | "Problem disconnecting a client", 137 | "err", err, 138 | ) 139 | } 140 | } 141 | 142 | // Path provides the current working directory of the client 143 | func (c *clientHandler) Path() string { 144 | c.paramsMutex.RLock() 145 | defer c.paramsMutex.RUnlock() 146 | 147 | return c.path 148 | } 149 | 150 | // SetPath changes the current working directory 151 | func (c *clientHandler) SetPath(value string) { 152 | c.paramsMutex.Lock() 153 | defer c.paramsMutex.Unlock() 154 | 155 | c.path = value 156 | } 157 | 158 | // getListPath returns the path for the last LIST/NLST request 159 | func (c *clientHandler) getListPath() string { 160 | c.paramsMutex.RLock() 161 | defer c.paramsMutex.RUnlock() 162 | 163 | return c.listPath 164 | } 165 | 166 | // SetListPath changes the path for the last LIST/NLST request 167 | func (c *clientHandler) SetListPath(value string) { 168 | c.paramsMutex.Lock() 169 | defer c.paramsMutex.Unlock() 170 | 171 | c.listPath = value 172 | } 173 | 174 | // Debug defines if we will list all interaction 175 | func (c *clientHandler) Debug() bool { 176 | c.paramsMutex.RLock() 177 | defer c.paramsMutex.RUnlock() 178 | 179 | return c.debug 180 | } 181 | 182 | // SetDebug changes the debug flag 183 | func (c *clientHandler) SetDebug(debug bool) { 184 | c.paramsMutex.Lock() 185 | defer c.paramsMutex.Unlock() 186 | 187 | c.debug = debug 188 | } 189 | 190 | // ID provides the client's ID 191 | func (c *clientHandler) ID() uint32 { 192 | return c.id 193 | } 194 | 195 | // RemoteAddr returns the remote network address. 196 | func (c *clientHandler) RemoteAddr() net.Addr { 197 | return c.conn.RemoteAddr() 198 | } 199 | 200 | // LocalAddr returns the local network address. 201 | func (c *clientHandler) LocalAddr() net.Addr { 202 | return c.conn.LocalAddr() 203 | } 204 | 205 | // GetClientVersion returns the identified client, can be empty. 206 | func (c *clientHandler) GetClientVersion() string { 207 | c.paramsMutex.RLock() 208 | defer c.paramsMutex.RUnlock() 209 | 210 | return c.clnt 211 | } 212 | 213 | func (c *clientHandler) setClientVersion(value string) { 214 | c.paramsMutex.Lock() 215 | defer c.paramsMutex.Unlock() 216 | 217 | c.clnt = value 218 | } 219 | 220 | // HasTLSForControl returns true if the control connection is over TLS 221 | func (c *clientHandler) HasTLSForControl() bool { 222 | if c.server.settings.TLSRequired == ImplicitEncryption { 223 | return true 224 | } 225 | 226 | c.paramsMutex.RLock() 227 | defer c.paramsMutex.RUnlock() 228 | 229 | return c.controlTLS 230 | } 231 | 232 | func (c *clientHandler) setTLSForControl(value bool) { 233 | c.paramsMutex.Lock() 234 | defer c.paramsMutex.Unlock() 235 | 236 | c.controlTLS = value 237 | } 238 | 239 | // HasTLSForTransfers returns true if the transfer connection is over TLS 240 | func (c *clientHandler) HasTLSForTransfers() bool { 241 | if c.server.settings.TLSRequired == ImplicitEncryption { 242 | return true 243 | } 244 | 245 | c.paramsMutex.RLock() 246 | defer c.paramsMutex.RUnlock() 247 | 248 | return c.transferTLS 249 | } 250 | 251 | func (c *clientHandler) SetExtra(extra any) { 252 | c.extra = extra 253 | } 254 | 255 | func (c *clientHandler) Extra() any { 256 | return c.extra 257 | } 258 | 259 | func (c *clientHandler) setTLSForTransfer(value bool) { 260 | c.paramsMutex.Lock() 261 | defer c.paramsMutex.Unlock() 262 | 263 | c.transferTLS = value 264 | } 265 | 266 | // SetTLSRequirement sets the TLS requirement to respect for this connection 267 | func (c *clientHandler) SetTLSRequirement(requirement TLSRequirement) error { 268 | if requirement < ClearOrEncrypted || requirement > MandatoryEncryption { 269 | return errInvalidTLSRequirement 270 | } 271 | 272 | c.paramsMutex.Lock() 273 | defer c.paramsMutex.Unlock() 274 | 275 | c.tlsRequirement = requirement 276 | 277 | return nil 278 | } 279 | 280 | func (c *clientHandler) isTLSRequired() bool { 281 | if c.server.settings.TLSRequired == MandatoryEncryption { 282 | return true 283 | } 284 | 285 | c.paramsMutex.RLock() 286 | defer c.paramsMutex.RUnlock() 287 | 288 | return c.tlsRequirement == MandatoryEncryption 289 | } 290 | 291 | // GetLastCommand returns the last received command 292 | func (c *clientHandler) GetLastCommand() string { 293 | c.paramsMutex.RLock() 294 | defer c.paramsMutex.RUnlock() 295 | 296 | return c.command 297 | } 298 | 299 | // GetLastDataChannel returns the last data channel mode 300 | func (c *clientHandler) GetLastDataChannel() DataChannel { 301 | c.paramsMutex.RLock() 302 | defer c.paramsMutex.RUnlock() 303 | 304 | return c.lastDataChannel 305 | } 306 | 307 | func (c *clientHandler) setLastCommand(cmd string) { 308 | c.paramsMutex.Lock() 309 | defer c.paramsMutex.Unlock() 310 | 311 | c.command = cmd 312 | } 313 | 314 | func (c *clientHandler) setLastDataChannel(channel DataChannel) { 315 | c.paramsMutex.Lock() 316 | defer c.paramsMutex.Unlock() 317 | 318 | c.lastDataChannel = channel 319 | } 320 | 321 | func (c *clientHandler) closeTransfer() error { 322 | var err error 323 | if c.transfer != nil { 324 | err = c.transfer.Close() 325 | c.isTransferOpen = false 326 | c.transfer = nil 327 | 328 | if c.debug { 329 | c.logger.Debug("Transfer connection closed") 330 | } 331 | } 332 | 333 | if err != nil { 334 | err = fmt.Errorf("error closing transfer connection: %w", err) 335 | } 336 | 337 | return err 338 | } 339 | 340 | // Close closes the active transfer, if any, and the control connection 341 | func (c *clientHandler) Close() error { 342 | c.transferMu.Lock() 343 | defer c.transferMu.Unlock() 344 | 345 | // set isTransferAborted to true so any transfer in progress will not try to write 346 | // to the closed connection on transfer close 347 | c.isTransferAborted = true 348 | 349 | if err := c.closeTransfer(); err != nil { 350 | c.logger.Warn( 351 | "Problem closing a transfer on external close request", 352 | "err", err, 353 | ) 354 | } 355 | 356 | // don't be tempted to send a message to the client before 357 | // closing the connection: 358 | // 359 | // 1) it is racy, we need to lock writeMessage to do this 360 | // 2) the client could wait for another response and so we break the protocol 361 | // 362 | // closing the connection from a different goroutine should be safe 363 | err := c.conn.Close() 364 | if err != nil { 365 | err = newNetworkError("error closing control connection", err) 366 | } 367 | 368 | return err 369 | } 370 | 371 | func (c *clientHandler) end() { 372 | c.server.driver.ClientDisconnected(c) 373 | c.server.clientDeparture(c) 374 | 375 | if err := c.conn.Close(); err != nil { 376 | c.logger.Debug( 377 | "Problem closing control connection", 378 | "err", err, 379 | ) 380 | } 381 | 382 | c.transferMu.Lock() 383 | defer c.transferMu.Unlock() 384 | 385 | if err := c.closeTransfer(); err != nil { 386 | c.logger.Warn( 387 | "Problem closing a transfer", 388 | "err", err, 389 | ) 390 | } 391 | } 392 | 393 | func (c *clientHandler) isCommandAborted() bool { 394 | c.transferMu.Lock() 395 | defer c.transferMu.Unlock() 396 | 397 | return c.isTransferAborted 398 | } 399 | 400 | // HandleCommands reads the stream of commands 401 | func (c *clientHandler) HandleCommands() { 402 | defer c.end() 403 | 404 | if msg, err := c.server.driver.ClientConnected(c); err == nil { 405 | c.writeMessage(StatusServiceReady, msg) 406 | } else { 407 | c.writeMessage(StatusSyntaxErrorNotRecognised, msg) 408 | 409 | return 410 | } 411 | 412 | for { 413 | if c.readCommand() { 414 | return 415 | } 416 | } 417 | } 418 | 419 | func (c *clientHandler) readCommand() bool { 420 | if c.reader == nil { 421 | if c.debug { 422 | c.logger.Debug("Client disconnected", "clean", true) 423 | } 424 | 425 | return true 426 | } 427 | 428 | // florent(2018-01-14): #58: IDLE timeout: Preparing the deadline before we read 429 | if c.server.settings.IdleTimeout > 0 { 430 | if err := c.conn.SetDeadline( 431 | time.Now().Add(time.Duration(time.Second.Nanoseconds() * int64(c.server.settings.IdleTimeout)))); err != nil { 432 | c.logger.Error("Network error", "err", err) 433 | } 434 | } 435 | 436 | lineSlice, isPrefix, err := c.reader.ReadLine() 437 | 438 | if isPrefix { 439 | if c.debug { 440 | c.logger.Warn("Received line too long, disconnecting client", 441 | "size", len(lineSlice)) 442 | } 443 | 444 | return true 445 | } 446 | 447 | if err != nil { 448 | c.handleCommandsStreamError(err) 449 | 450 | return true 451 | } 452 | 453 | line := string(lineSlice) 454 | 455 | if c.debug { 456 | c.logger.Debug("Received line", "line", line) 457 | } 458 | 459 | c.handleCommand(line) 460 | 461 | return false 462 | } 463 | 464 | func (c *clientHandler) handleCommandsStreamError(err error) { 465 | // florent(2018-01-14): #58: IDLE timeout: Adding some code to deal with the deadline 466 | var errNetError net.Error 467 | if errors.As(err, &errNetError) { //nolint:nestif // too much effort to change for now 468 | if errNetError.Timeout() { 469 | // We have to extend the deadline now 470 | if errSet := c.conn.SetDeadline(time.Now().Add(time.Minute)); errSet != nil { 471 | c.logger.Error("Could not set read deadline", "err", errSet) 472 | } 473 | 474 | c.logger.Info("Client IDLE timeout", "err", err) 475 | c.writeMessage( 476 | StatusServiceNotAvailable, 477 | fmt.Sprintf("command timeout (%d seconds): closing control connection", c.server.settings.IdleTimeout)) 478 | 479 | if errFlush := c.writer.Flush(); errFlush != nil { 480 | c.logger.Error("Flush error", "err", errFlush) 481 | } 482 | 483 | return 484 | } 485 | 486 | c.logger.Error("Network error", "err", err) 487 | } else { 488 | if errors.Is(err, io.EOF) { 489 | if c.debug { 490 | c.logger.Debug("Client disconnected", "clean", false) 491 | } 492 | } else { 493 | c.logger.Error("Read error", "err", err) 494 | } 495 | } 496 | } 497 | 498 | // handleCommand takes care of executing the received line 499 | func (c *clientHandler) handleCommand(line string) { 500 | command, param := parseLine(line) 501 | command = strings.ToUpper(command) 502 | 503 | cmdDesc := commandsMap[command] 504 | if cmdDesc == nil { 505 | // Search among commands having a "special semantic". They 506 | // should be sent by following the RFC-959 procedure of sending 507 | // Telnet IP/Synch sequence (chr 242 and 255) as OOB data but 508 | // since many ftp clients don't do it correctly we check the 509 | // command suffix. 510 | for _, cmd := range specialAttentionCommands { 511 | if strings.HasSuffix(command, cmd) { 512 | cmdDesc = commandsMap[cmd] 513 | command = cmd 514 | 515 | break 516 | } 517 | } 518 | 519 | if cmdDesc == nil { 520 | c.logger.Warn("Unknown command", "command", command) 521 | c.setLastCommand(command) 522 | c.writeMessage(StatusSyntaxErrorNotRecognised, fmt.Sprintf("Unknown command %#v", command)) 523 | 524 | return 525 | } 526 | } 527 | 528 | if c.driver == nil && !cmdDesc.Open { 529 | c.writeMessage(StatusNotLoggedIn, "Please login with USER and PASS") 530 | 531 | return 532 | } 533 | 534 | // All commands are serialized except the ones that require special action. 535 | // Special action commands are not executed in a separate goroutine so we can 536 | // have at most one command that can open a transfer connection and one special 537 | // action command running at the same time. 538 | // Only server STAT is a special action command so we do an additional check here 539 | if !cmdDesc.SpecialAction || (command == "STAT" && param != "") { 540 | c.transferWg.Wait() 541 | } 542 | 543 | c.setLastCommand(command) 544 | 545 | if cmdDesc.TransferRelated { 546 | // these commands will be started in a separate goroutine so 547 | // they can be aborted. 548 | // We cannot have two concurrent transfers so also set isTransferAborted 549 | // to false here. 550 | // isTransferAborted could remain to true if the previous command is 551 | // aborted and it does not open a transfer connection, see "transferFile" 552 | // for details. For this to happen a client should send an ABOR before 553 | // receiving the StatusFileStatusOK response. This is very unlikely 554 | // A lock is not required here, we cannot have another concurrent ABOR 555 | // or transfer active here 556 | c.isTransferAborted = false 557 | 558 | c.transferWg.Add(1) 559 | 560 | go func(cmd, param string) { 561 | defer c.transferWg.Done() 562 | 563 | c.executeCommandFn(cmdDesc, cmd, param) 564 | }(command, param) 565 | } else { 566 | c.executeCommandFn(cmdDesc, command, param) 567 | } 568 | } 569 | 570 | func (c *clientHandler) executeCommandFn(cmdDesc *CommandDescription, command, param string) { 571 | // Let's prepare to recover in case there's a command error 572 | defer func() { 573 | if r := recover(); r != nil { 574 | c.writeMessage(StatusSyntaxErrorNotRecognised, fmt.Sprintf("Unhandled internal error: %s", r)) 575 | c.logger.Warn( 576 | "Internal command handling error", 577 | "err", r, 578 | "command", command, 579 | "param", param, 580 | ) 581 | } 582 | }() 583 | 584 | if err := cmdDesc.Fn(c, param); err != nil { 585 | c.writeMessage(StatusSyntaxErrorNotRecognised, fmt.Sprintf("Error: %s", err)) 586 | } 587 | } 588 | 589 | func (c *clientHandler) writeLine(line string) { 590 | if c.debug { 591 | c.logger.Debug("Sending answer", "line", line) 592 | } 593 | 594 | if _, err := fmt.Fprintf(c.writer, "%s\r\n", line); err != nil { 595 | c.logger.Warn( 596 | "Answer couldn't be sent", 597 | "line", line, 598 | "err", err, 599 | ) 600 | } 601 | 602 | if err := c.writer.Flush(); err != nil { 603 | c.logger.Warn( 604 | "Couldn't flush line", 605 | "err", err, 606 | ) 607 | } 608 | } 609 | 610 | func (c *clientHandler) writeMessage(code int, message string) { 611 | lines := getMessageLines(message) 612 | 613 | for idx, line := range lines { 614 | if idx < len(lines)-1 { 615 | c.writeLine(fmt.Sprintf("%d-%s", code, line)) 616 | } else { 617 | c.writeLine(fmt.Sprintf("%d %s", code, line)) 618 | } 619 | } 620 | } 621 | 622 | func (c *clientHandler) GetTranferInfo() string { 623 | if c.transfer == nil { 624 | return "" 625 | } 626 | 627 | return c.transfer.GetInfo() 628 | } 629 | 630 | func (c *clientHandler) TransferOpen(info string) (net.Conn, error) { 631 | c.transferMu.Lock() 632 | defer c.transferMu.Unlock() 633 | 634 | if c.transfer == nil { 635 | // a transfer could be aborted before it is opened, in this case no response should be returned 636 | if c.isTransferAborted { 637 | c.isTransferAborted = false 638 | 639 | return nil, errNoTransferConnection 640 | } 641 | 642 | c.writeMessage(StatusActionNotTaken, errNoTransferConnection.Error()) 643 | 644 | return nil, errNoTransferConnection 645 | } 646 | 647 | if c.isTLSRequired() && !c.HasTLSForTransfers() { 648 | c.writeMessage(StatusServiceNotAvailable, errTLSRequired.Error()) 649 | 650 | return nil, errTLSRequired 651 | } 652 | 653 | conn, err := c.transfer.Open() 654 | if err != nil { 655 | c.logger.Warn( 656 | "Unable to open transfer", 657 | "error", err) 658 | 659 | c.writeMessage(StatusCannotOpenDataConnection, err.Error()) 660 | 661 | err = newNetworkError("Unable to open transfer", err) 662 | 663 | return nil, err 664 | } 665 | 666 | c.isTransferOpen = true 667 | c.transfer.SetInfo(info) 668 | 669 | c.writeMessage(StatusFileStatusOK, "Using transfer connection") 670 | 671 | if c.debug { 672 | c.logger.Debug( 673 | "Transfer connection opened", 674 | "remoteAddr", conn.RemoteAddr().String(), 675 | "localAddr", conn.LocalAddr().String()) 676 | } 677 | 678 | return conn, nil 679 | } 680 | 681 | func (c *clientHandler) TransferClose(err error) { 682 | c.transferMu.Lock() 683 | defer c.transferMu.Unlock() 684 | 685 | errClose := c.closeTransfer() 686 | if errClose != nil { 687 | c.logger.Warn( 688 | "Problem closing transfer connection", 689 | "err", err, 690 | ) 691 | } 692 | 693 | // if the transfer was aborted we don't have to send a response 694 | if c.isTransferAborted { 695 | c.isTransferAborted = false 696 | 697 | return 698 | } 699 | 700 | switch { 701 | case err == nil && errClose == nil: 702 | c.writeMessage(StatusClosingDataConn, "Closing transfer connection") 703 | case errClose != nil: 704 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("Issue during transfer close: %v", errClose)) 705 | case err != nil: 706 | c.writeMessage(getErrorCode(err, StatusActionNotTaken), fmt.Sprintf("Issue during transfer: %v", err)) 707 | } 708 | } 709 | 710 | func (c *clientHandler) checkDataConnectionRequirement(dataConnIP net.IP, channelType DataChannel) error { 711 | var requirement DataConnectionRequirement 712 | 713 | switch channelType { 714 | case DataChannelActive: 715 | requirement = c.server.settings.ActiveConnectionsCheck 716 | case DataChannelPassive: 717 | requirement = c.server.settings.PasvConnectionsCheck 718 | } 719 | 720 | switch requirement { 721 | case IPMatchRequired: 722 | controlConnIP, err := getIPFromRemoteAddr(c.RemoteAddr()) 723 | if err != nil { 724 | return err 725 | } 726 | 727 | if !controlConnIP.Equal(dataConnIP) { 728 | return &ipValidationError{error: fmt.Sprintf("data connection ip address %v "+ 729 | "does not match control connection ip address %v", 730 | dataConnIP, controlConnIP)} 731 | } 732 | 733 | return nil 734 | case IPMatchDisabled: 735 | return nil 736 | default: 737 | return &ipValidationError{error: fmt.Sprintf("unhandled data connection requirement: %v", 738 | requirement)} 739 | } 740 | } 741 | 742 | func getIPFromRemoteAddr(remoteAddr net.Addr) (net.IP, error) { 743 | if remoteAddr == nil { 744 | return nil, &ipValidationError{error: "nil remote address"} 745 | } 746 | 747 | ipAddress, _, err := net.SplitHostPort(remoteAddr.String()) 748 | if err != nil { 749 | return nil, fmt.Errorf("error parsing remote address: %w", err) 750 | } 751 | 752 | remoteIP := net.ParseIP(ipAddress) 753 | if remoteIP == nil { 754 | return nil, &ipValidationError{error: fmt.Sprintf("invalid remote IP: %v", ipAddress)} 755 | } 756 | 757 | return remoteIP, nil 758 | } 759 | 760 | func parseLine(line string) (string, string) { 761 | params := strings.SplitN(line, " ", 2) 762 | if len(params) == 1 { 763 | return params[0], "" 764 | } 765 | 766 | return params[0], params[1] 767 | } 768 | 769 | func (c *clientHandler) multilineAnswer(code int, message string) func() { 770 | c.writeLine(fmt.Sprintf("%d-%s", code, message)) 771 | 772 | return func() { 773 | c.writeLine(fmt.Sprintf("%d End", code)) 774 | } 775 | } 776 | 777 | func getMessageLines(message string) []string { 778 | lines := make([]string, 0, 1) 779 | sc := bufio.NewScanner(strings.NewReader(message)) 780 | 781 | for sc.Scan() { 782 | lines = append(lines, sc.Text()) 783 | } 784 | 785 | if len(lines) == 0 { 786 | lines = append(lines, "") 787 | } 788 | 789 | return lines 790 | } 791 | -------------------------------------------------------------------------------- /client_handler_test.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/secsy/goftp" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestConcurrency(t *testing.T) { 16 | server := NewTestServer(t, false) 17 | 18 | nbClients := 100 19 | 20 | waitGroup := sync.WaitGroup{} 21 | waitGroup.Add(nbClients) 22 | 23 | for i := 0; i < nbClients; i++ { 24 | go func() { 25 | conf := goftp.Config{ 26 | User: authUser, 27 | Password: authPass, 28 | } 29 | 30 | client, err := goftp.DialConfig(conf, server.Addr()) 31 | if err != nil { 32 | panic(fmt.Sprintf("Couldn't connect: %v", err)) 33 | } 34 | 35 | if _, err = client.ReadDir("/"); err != nil { 36 | panic(fmt.Sprintf("Couldn't list dir: %v", err)) 37 | } 38 | 39 | defer func() { panicOnError(client.Close()) }() 40 | 41 | waitGroup.Done() 42 | }() 43 | } 44 | 45 | waitGroup.Wait() 46 | } 47 | 48 | func TestDOS(t *testing.T) { 49 | server := NewTestServer(t, true) 50 | conn, err := net.DialTimeout("tcp", server.Addr(), 5*time.Second) 51 | require.NoError(t, err) 52 | 53 | defer func() { 54 | err = conn.Close() 55 | require.NoError(t, err) 56 | }() 57 | 58 | buf := make([]byte, 128) 59 | readBytes, err := conn.Read(buf) 60 | require.NoError(t, err) 61 | 62 | response := string(buf[:readBytes]) 63 | require.Equal(t, "220 TEST Server\r\n", response) 64 | 65 | written := 0 66 | 67 | for { 68 | readBytes, err = conn.Write([]byte("some text without line ending")) 69 | written += readBytes 70 | 71 | if err != nil { 72 | break 73 | } 74 | 75 | if written > 4096 { 76 | server.Logger.Warn("test DOS", 77 | "bytes written", written) 78 | } 79 | } 80 | } 81 | 82 | func TestLastCommand(t *testing.T) { 83 | cc := clientHandler{} 84 | assert.Empty(t, cc.GetLastCommand()) 85 | } 86 | 87 | func TestLastDataChannel(t *testing.T) { 88 | cc := clientHandler{lastDataChannel: DataChannelPassive} 89 | assert.Equal(t, DataChannelPassive, cc.GetLastDataChannel()) 90 | } 91 | 92 | func TestTransferOpenError(t *testing.T) { 93 | server := NewTestServer(t, false) 94 | conf := goftp.Config{ 95 | User: authUser, 96 | Password: authPass, 97 | } 98 | 99 | client, err := goftp.DialConfig(conf, server.Addr()) 100 | require.NoError(t, err, "Couldn't connect") 101 | 102 | defer func() { panicOnError(client.Close()) }() 103 | 104 | raw, err := client.OpenRawConn() 105 | require.NoError(t, err, "Couldn't open raw connection") 106 | 107 | defer func() { require.NoError(t, raw.Close()) }() 108 | 109 | // we send STOR without opening a transfer connection 110 | rc, response, err := raw.SendCommand("STOR file") 111 | require.NoError(t, err) 112 | require.Equal(t, StatusActionNotTaken, rc) 113 | require.Equal(t, "unable to open transfer: no transfer connection", response) 114 | } 115 | 116 | func TestTLSMethods(t *testing.T) { 117 | t.Parallel() 118 | 119 | t.Run("without-tls", func(t *testing.T) { 120 | t.Parallel() 121 | cc := clientHandler{ 122 | server: NewTestServer(t, false), 123 | } 124 | require.False(t, cc.HasTLSForControl()) 125 | require.False(t, cc.HasTLSForTransfers()) 126 | }) 127 | 128 | t.Run("with-implicit-tls", func(t *testing.T) { 129 | t.Parallel() 130 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 131 | Settings: &Settings{ 132 | TLSRequired: ImplicitEncryption, 133 | }, 134 | TLS: true, 135 | Debug: false, 136 | }) 137 | cc := clientHandler{ 138 | server: server, 139 | } 140 | require.True(t, cc.HasTLSForControl()) 141 | require.True(t, cc.HasTLSForTransfers()) 142 | }) 143 | } 144 | 145 | func TestConnectionNotAllowed(t *testing.T) { 146 | driver := &TestServerDriver{ 147 | Debug: true, 148 | CloseOnConnect: true, 149 | } 150 | s := NewTestServerWithTestDriver(t, driver) 151 | 152 | conn, err := net.DialTimeout("tcp", s.Addr(), 5*time.Second) 153 | require.NoError(t, err) 154 | 155 | defer func() { 156 | err = conn.Close() 157 | require.NoError(t, err) 158 | }() 159 | 160 | buf := make([]byte, 128) 161 | n, err := conn.Read(buf) 162 | require.NoError(t, err) 163 | 164 | response := string(buf[:n]) 165 | require.Equal(t, "500 TEST Server\r\n", response) 166 | 167 | _, err = conn.Write([]byte("NOOP\r\n")) 168 | require.NoError(t, err) 169 | 170 | _, err = conn.Read(buf) 171 | require.Error(t, err) 172 | } 173 | 174 | func TestCloseConnection(t *testing.T) { 175 | driver := &TestServerDriver{ 176 | Debug: false, 177 | } 178 | server := NewTestServerWithTestDriver(t, driver) 179 | 180 | conf := goftp.Config{ 181 | User: authUser, 182 | Password: authPass, 183 | } 184 | 185 | client, err := goftp.DialConfig(conf, server.Addr()) 186 | require.NoError(t, err, "Couldn't connect") 187 | 188 | ftpUpload(t, client, createTemporaryFile(t, 1024*1024), "file.bin") 189 | 190 | require.Len(t, driver.GetClientsInfo(), 1) 191 | 192 | err = client.Rename("file.bin", "delay-io.bin") 193 | require.NoError(t, err) 194 | 195 | raw, err := client.OpenRawConn() 196 | require.NoError(t, err) 197 | 198 | defer func() { require.NoError(t, raw.Close()) }() 199 | 200 | require.Len(t, driver.GetClientsInfo(), 2) 201 | 202 | err = driver.DisconnectClient() 203 | require.NoError(t, err) 204 | 205 | assert.Eventually(t, func() bool { 206 | return len(driver.GetClientsInfo()) == 1 207 | }, 1*time.Second, 50*time.Millisecond) 208 | 209 | err = driver.DisconnectClient() 210 | require.NoError(t, err) 211 | 212 | assert.Eventually(t, func() bool { 213 | return len(driver.GetClientsInfo()) == 0 214 | }, 1*time.Second, 50*time.Millisecond) 215 | } 216 | 217 | func TestClientContextConcurrency(t *testing.T) { 218 | driver := &TestServerDriver{} 219 | server := NewTestServerWithTestDriver(t, driver) 220 | 221 | conf := goftp.Config{ 222 | User: authUser, 223 | Password: authPass, 224 | } 225 | 226 | client, err := goftp.DialConfig(conf, server.Addr()) 227 | require.NoError(t, err, "Couldn't connect") 228 | 229 | defer func() { panicOnError(client.Close()) }() 230 | 231 | done := make(chan bool, 1) 232 | connected := make(chan bool, 1) 233 | 234 | go func() { 235 | _, err := client.Getwd() 236 | assert.NoError(t, err) 237 | connected <- true 238 | 239 | counter := 0 240 | 241 | for counter < 100 { 242 | _, err := client.Getwd() 243 | assert.NoError(t, err) 244 | 245 | counter++ 246 | } 247 | 248 | done <- true 249 | }() 250 | 251 | <-connected 252 | 253 | isDone := false 254 | for !isDone { 255 | info := driver.GetClientsInfo() 256 | assert.Len(t, info, 1) 257 | 258 | select { 259 | case <-done: 260 | isDone = true 261 | default: 262 | } 263 | } 264 | } 265 | 266 | type multilineMessage struct { 267 | message string 268 | expectedLines []string 269 | } 270 | 271 | func TestMultiLineMessages(t *testing.T) { 272 | testMultilines := []multilineMessage{ 273 | { 274 | message: "single line", 275 | expectedLines: []string{"single line"}, 276 | }, 277 | { 278 | message: "", 279 | expectedLines: []string{""}, 280 | }, 281 | { 282 | message: "first line\r\nsecond line\r\n", 283 | expectedLines: []string{"first line", "second line"}, 284 | }, 285 | { 286 | message: "first line\nsecond line\n", 287 | expectedLines: []string{"first line", "second line"}, 288 | }, 289 | { 290 | message: "first line\rsecond line", 291 | expectedLines: []string{"first line\rsecond line"}, 292 | }, 293 | { 294 | message: `first line 295 | 296 | second line 297 | 298 | `, 299 | expectedLines: []string{"first line", "", "second line", ""}, 300 | }, 301 | } 302 | 303 | for _, msg := range testMultilines { 304 | lines := getMessageLines(msg.message) 305 | if len(lines) != len(msg.expectedLines) { 306 | t.Errorf("unexpected number of lines got: %v want: %v", len(lines), len(msg.expectedLines)) 307 | } 308 | 309 | for _, line := range lines { 310 | if !isStringInSlice(line, msg.expectedLines) { 311 | t.Errorf("unexpected line %#v", line) 312 | } 313 | } 314 | } 315 | } 316 | 317 | func isStringInSlice(s string, list []string) bool { 318 | for _, c := range list { 319 | if s == c { 320 | return true 321 | } 322 | } 323 | 324 | return false 325 | } 326 | 327 | func TestUnknownCommand(t *testing.T) { 328 | server := NewTestServer(t, false) 329 | conf := goftp.Config{ 330 | User: authUser, 331 | Password: authPass, 332 | } 333 | 334 | c, err := goftp.DialConfig(conf, server.Addr()) 335 | require.NoError(t, err, "Couldn't connect") 336 | 337 | defer func() { panicOnError(c.Close()) }() 338 | 339 | raw, err := c.OpenRawConn() 340 | require.NoError(t, err, "Couldn't open raw connection") 341 | 342 | defer func() { require.NoError(t, raw.Close()) }() 343 | 344 | cmd := "UNSUPPORTED" 345 | rc, response, err := raw.SendCommand(cmd) 346 | require.NoError(t, err) 347 | require.Equal(t, StatusSyntaxErrorNotRecognised, rc) 348 | require.Equal(t, fmt.Sprintf("Unknown command %#v", cmd), response) 349 | } 350 | 351 | // testNetConn implements net.Conn interface 352 | type testNetConn struct { 353 | remoteAddr net.Addr 354 | } 355 | 356 | func (*testNetConn) Read(_ []byte) (int, error) { 357 | return 0, nil 358 | } 359 | 360 | func (*testNetConn) Write(_ []byte) (int, error) { 361 | return 0, nil 362 | } 363 | 364 | func (*testNetConn) Close() error { 365 | return nil 366 | } 367 | 368 | func (*testNetConn) LocalAddr() net.Addr { 369 | return nil 370 | } 371 | 372 | func (c *testNetConn) RemoteAddr() net.Addr { 373 | return c.remoteAddr 374 | } 375 | 376 | func (*testNetConn) SetDeadline(_ time.Time) error { 377 | return nil 378 | } 379 | 380 | func (*testNetConn) SetReadDeadline(_ time.Time) error { 381 | return nil 382 | } 383 | 384 | func (*testNetConn) SetWriteDeadline(_ time.Time) error { 385 | return nil 386 | } 387 | 388 | // testNetListener implements net.Listener interface 389 | type testNetListener struct { 390 | conn net.Conn 391 | } 392 | 393 | func (l *testNetListener) Accept() (net.Conn, error) { 394 | if l.conn != nil { 395 | return l.conn, nil 396 | } 397 | 398 | return nil, &net.AddrError{} 399 | } 400 | 401 | func (*testNetListener) Close() error { 402 | return nil 403 | } 404 | 405 | func (*testNetListener) Addr() net.Addr { 406 | return nil 407 | } 408 | 409 | func TestDataConnectionRequirements(t *testing.T) { 410 | req := require.New(t) 411 | controlConnIP := net.ParseIP("192.168.1.1") 412 | 413 | cltHandler := clientHandler{ 414 | conn: &testNetConn{ 415 | remoteAddr: &net.TCPAddr{IP: controlConnIP, Port: 21}, 416 | }, 417 | server: &FtpServer{ 418 | settings: &Settings{ 419 | PasvConnectionsCheck: IPMatchRequired, 420 | ActiveConnectionsCheck: IPMatchRequired, 421 | }, 422 | }, 423 | } 424 | 425 | err := cltHandler.checkDataConnectionRequirement(controlConnIP, DataChannelPassive) 426 | req.NoError(err) // ip match 427 | 428 | err = cltHandler.checkDataConnectionRequirement(net.ParseIP("192.168.1.2"), DataChannelActive) 429 | if assert.Error(t, err) { 430 | assert.Contains(t, err.Error(), "does not match control connection ip address") 431 | } 432 | 433 | cltHandler.conn = &testNetConn{ 434 | remoteAddr: &net.IPAddr{IP: controlConnIP}, 435 | } 436 | 437 | err = cltHandler.checkDataConnectionRequirement(controlConnIP, DataChannelPassive) 438 | req.Error(err) 439 | 440 | // nil remote address 441 | cltHandler.conn = &testNetConn{} 442 | err = cltHandler.checkDataConnectionRequirement(controlConnIP, DataChannelActive) 443 | req.Error(err) 444 | 445 | // invalid IP 446 | cltHandler.conn = &testNetConn{ 447 | remoteAddr: &net.TCPAddr{IP: nil, Port: 21}, 448 | } 449 | 450 | err = cltHandler.checkDataConnectionRequirement(controlConnIP, DataChannelPassive) 451 | if assert.Error(t, err) { 452 | assert.Contains(t, err.Error(), "invalid remote IP") 453 | } 454 | 455 | // invalid setting 456 | cltHandler.server.settings.PasvConnectionsCheck = 100 457 | err = cltHandler.checkDataConnectionRequirement(controlConnIP, DataChannelPassive) 458 | 459 | if assert.Error(t, err) { 460 | assert.Contains(t, err.Error(), "unhandled data connection requirement") 461 | } 462 | } 463 | 464 | func TestExtraData(t *testing.T) { 465 | driver := &TestServerDriver{ 466 | Debug: false, 467 | } 468 | server := NewTestServerWithTestDriver(t, driver) 469 | 470 | conf := goftp.Config{ 471 | User: authUser, 472 | Password: authPass, 473 | } 474 | 475 | c, err := goftp.DialConfig(conf, server.Addr()) 476 | require.NoError(t, err, "Couldn't connect") 477 | 478 | defer func() { panicOnError(c.Close()) }() 479 | 480 | raw, err := c.OpenRawConn() 481 | require.NoError(t, err) 482 | 483 | defer func() { require.NoError(t, raw.Close()) }() 484 | 485 | info := driver.GetClientsInfo() 486 | require.Len(t, info, 1) 487 | 488 | for k, v := range info { 489 | ccInfo, ok := v.(map[string]interface{}) 490 | require.True(t, ok) 491 | extra, ok := ccInfo["extra"].(uint32) 492 | require.True(t, ok) 493 | require.Equal(t, k, extra) 494 | } 495 | } 496 | -------------------------------------------------------------------------------- /consts.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | // from @stevenh's PR proposal 4 | // https://github.com/fclairamb/ftpserverlib/blob/becc125a0770e3b670c4ced7e7bd12594fb024ff/server/consts.go 5 | 6 | // Status codes as documented by: 7 | // https://tools.ietf.org/html/rfc959 8 | // https://tools.ietf.org/html/rfc2428 9 | // https://tools.ietf.org/html/rfc2228 10 | const ( 11 | // 100 Series - The requested action is being initiated, expect another reply before 12 | // proceeding with a new command. 13 | StatusFileStatusOK = 150 // RFC 959, 4.2.1 14 | 15 | // 200 Series - The requested action has been successfully completed. 16 | StatusOK = 200 // RFC 959, 4.2.1 17 | StatusNotImplemented = 202 // RFC 959, 4.2.1 18 | StatusSystemStatus = 211 // RFC 959, 4.2.1 19 | StatusDirectoryStatus = 212 // RFC 959, 4.2.1 20 | StatusFileStatus = 213 // RFC 959, 4.2.1 21 | StatusHelpMessage = 214 // RFC 959, 4.2.1 22 | StatusSystemType = 215 // RFC 959, 4.2.1 23 | StatusServiceReady = 220 // RFC 959, 4.2.1 24 | StatusClosingControlConn = 221 // RFC 959, 4.2.1 25 | StatusClosingDataConn = 226 // RFC 959, 4.2.1 26 | StatusEnteringPASV = 227 // RFC 959, 4.2.1 27 | StatusEnteringEPSV = 229 // RFC 2428, 3 28 | StatusUserLoggedIn = 230 // RFC 959, 4.2.1 29 | StatusAuthAccepted = 234 // RFC 2228, 3 30 | StatusFileOK = 250 // RFC 959, 4.2.1 31 | StatusPathCreated = 257 // RFC 959, 4.2.1 32 | 33 | // 300 Series - The command has been accepted, but the requested action is on hold, 34 | // pending receipt of further information. 35 | StatusUserOK = 331 // RFC 959, 4.2.1 36 | StatusFileActionPending = 350 // RFC 959, 4.2.1 37 | 38 | // 400 Series - The command was not accepted and the requested action did not take place, 39 | // but the error condition is temporary and the action may be requested again. 40 | StatusServiceNotAvailable = 421 // RFC 959, 4.2.1 41 | StatusCannotOpenDataConnection = 425 // RFC 959, 4.2.1 42 | StatusTransferAborted = 426 // RFC 959, 4.2.1 43 | StatusFileActionNotTaken = 450 // RFC 959, 4.2.1 44 | 45 | // 500 Series - Syntax error, command unrecognized and the requested action did not take 46 | // place. This may include errors such as command line too long. 47 | StatusSyntaxErrorNotRecognised = 500 // RFC 959, 4.2.1 48 | StatusSyntaxErrorParameters = 501 // RFC 959, 4.2.1 49 | StatusCommandNotImplemented = 502 // RFC 959, 4.2.1 50 | StatusBadCommandSequence = 503 // RFC 959, 4.2.1 51 | StatusNotImplementedParam = 504 // RFC 959, 4.2.1 52 | StatusNotLoggedIn = 530 // RFC 959, 4.2.1 53 | StatusActionNotTaken = 550 // RFC 959, 4.2.1 54 | StatusActionAborted = 552 // RFC 959, 4.2.1 55 | StatusActionNotTakenNoFile = 553 // RFC 959, 4.2.1 56 | ) 57 | -------------------------------------------------------------------------------- /control_fallback.go: -------------------------------------------------------------------------------- 1 | //go:build !linux && !freebsd && !darwin && !aix && !dragonfly && !netbsd && !openbsd && !windows 2 | // +build !linux,!freebsd,!darwin,!aix,!dragonfly,!netbsd,!openbsd,!windows 3 | 4 | package ftpserver 5 | 6 | import ( 7 | "syscall" 8 | ) 9 | 10 | // Control defines the function to use as dialer Control to reuse the same port/address. 11 | // This fallback implementation does nothing 12 | func Control(network, address string, c syscall.RawConn) error { 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /control_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || freebsd || darwin || aix || dragonfly || netbsd || openbsd 2 | // +build linux freebsd darwin aix dragonfly netbsd openbsd 3 | 4 | package ftpserver 5 | 6 | import ( 7 | "fmt" 8 | "syscall" 9 | 10 | "golang.org/x/sys/unix" 11 | ) 12 | 13 | // Control defines the function to use as dialer Control to reuse the same port/address 14 | func Control(_, _ string, c syscall.RawConn) error { 15 | var errSetOpts error 16 | 17 | err := c.Control(func(unixFd uintptr) { 18 | errSetOpts = unix.SetsockoptInt(int(unixFd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1) 19 | if errSetOpts != nil { 20 | return 21 | } 22 | 23 | errSetOpts = unix.SetsockoptInt(int(unixFd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1) 24 | if errSetOpts != nil { 25 | return 26 | } 27 | }) 28 | if err != nil { 29 | return fmt.Errorf("unable to set control options: %w", err) 30 | } 31 | 32 | if errSetOpts != nil { 33 | errSetOpts = fmt.Errorf("unable to set control options: %w", errSetOpts) 34 | } 35 | 36 | return errSetOpts 37 | } 38 | -------------------------------------------------------------------------------- /control_windows.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "syscall" 5 | 6 | "golang.org/x/sys/windows" 7 | ) 8 | 9 | // Control defines the function to use as dialer Control to reuse the same port/address 10 | func Control(network, address string, c syscall.RawConn) error { 11 | var errSetOpts error 12 | 13 | err := c.Control(func(fd uintptr) { 14 | errSetOpts = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1) 15 | }) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | return errSetOpts 21 | } 22 | -------------------------------------------------------------------------------- /driver.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "crypto/tls" 5 | "io" 6 | "net" 7 | "os" 8 | 9 | "github.com/spf13/afero" 10 | ) 11 | 12 | // This file is the driver part of the server. It must be implemented by anyone wanting to use the server. 13 | 14 | // MainDriver handles the authentication and ClientHandlingDriver selection 15 | type MainDriver interface { 16 | // GetSettings returns some general settings around the server setup 17 | GetSettings() (*Settings, error) 18 | 19 | // ClientConnected is called to send the very first welcome message 20 | ClientConnected(cc ClientContext) (string, error) 21 | 22 | // ClientDisconnected is called when the user disconnects, even if he never authenticated 23 | ClientDisconnected(cc ClientContext) 24 | 25 | // AuthUser authenticates the user and selects an handling driver 26 | AuthUser(cc ClientContext, user, pass string) (ClientDriver, error) 27 | 28 | // GetTLSConfig returns a TLS Certificate to use 29 | // The certificate could frequently change if we use something like "let's encrypt" 30 | GetTLSConfig() (*tls.Config, error) 31 | } 32 | 33 | // MainDriverExtensionTLSVerifier is an extension that allows to verify the TLS connection 34 | // estabilished on the control channel 35 | type MainDriverExtensionTLSVerifier interface { 36 | // VerifyConnection is called when receiving the "USER" command. 37 | // If it returns a non-nil error, the client will receive a 530 error and it will be disconnected. 38 | // If it returns a non-nil ClientDriver and a nil error the client will be authenticated. 39 | // If it returns a nil ClientDriver and a nil error the user password is required 40 | VerifyConnection(cc ClientContext, user string, tlsConn *tls.Conn) (ClientDriver, error) 41 | } 42 | 43 | // MainDriverExtensionPassiveWrapper is an extension that allows to wrap the listener 44 | // used for passive connection 45 | type MainDriverExtensionPassiveWrapper interface { 46 | // WrapPassiveListener is called after creating the listener for passive 47 | // data connections. 48 | // You can wrap the passed listener or just return it unmodified. 49 | // Returning an error will cause the passive connection to fail 50 | WrapPassiveListener(listener net.Listener) (net.Listener, error) 51 | } 52 | 53 | // MainDriverExtensionUserVerifier is an extension that allows to control user access 54 | // once username is known, before the authentication 55 | type MainDriverExtensionUserVerifier interface { 56 | // PreAuthUser is called when receiving the "USER" command before proceeding with any other checks 57 | // If it returns a non-nil error, the client will receive a 530 error and be disconnected. 58 | PreAuthUser(cc ClientContext, user string) error 59 | } 60 | 61 | // MainDriverExtensionPostAuthMessage is an extension that allows to send a message 62 | // after the authentication 63 | type MainDriverExtensionPostAuthMessage interface { 64 | // PostAuthMessage is called after the authentication 65 | PostAuthMessage(cc ClientContext, user string, authErr error) string 66 | } 67 | 68 | // MainDriverExtensionQuitMessage is an extension that allows to control the quit message 69 | type MainDriverExtensionQuitMessage interface { 70 | // QuitMessage returns the message to display when the user quits the server 71 | QuitMessage() string 72 | } 73 | 74 | // ClientDriver is the base FS implementation that allows to manipulate files 75 | type ClientDriver interface { 76 | afero.Fs 77 | } 78 | 79 | // ClientDriverExtensionAllocate is an extension to support the "ALLO" - file allocation - command 80 | type ClientDriverExtensionAllocate interface { 81 | // AllocateSpace reserves the space necessary to upload files 82 | AllocateSpace(size int) error 83 | } 84 | 85 | // ClientDriverExtensionSymlink is an extension to support the "SITE SYMLINK" - symbolic link creation - command 86 | type ClientDriverExtensionSymlink interface { 87 | // Symlink creates a symlink 88 | Symlink(oldname, newname string) error 89 | 90 | // SymlinkIfPossible allows to get the source of a symlink (but we don't need for now) 91 | // ReadlinkIfPossible(name string) (string, error) 92 | } 93 | 94 | // ClientDriverExtensionFileList is a convenience extension to allow to return file listing 95 | // without requiring to implement the methods Open/Readdir for your custom afero.File 96 | type ClientDriverExtensionFileList interface { 97 | // ReadDir reads the directory named by name and return a list of directory entries. 98 | ReadDir(name string) ([]os.FileInfo, error) 99 | } 100 | 101 | // ClientDriverExtentionFileTransfer is a convenience extension to allow to transfer files 102 | // without requiring to implement the methods Create/Open/OpenFile for your custom afero.File. 103 | type ClientDriverExtentionFileTransfer interface { 104 | // GetHandle return an handle to upload or download a file based on flags: 105 | // os.O_RDONLY indicates a download 106 | // os.O_WRONLY indicates an upload and can be combined with os.O_APPEND (resume) or 107 | // os.O_CREATE (upload to new file/truncate) 108 | // 109 | // offset is the argument of a previous REST command, if any, or 0 110 | GetHandle(name string, flags int, offset int64) (FileTransfer, error) 111 | } 112 | 113 | // ClientDriverExtensionRemoveDir is an extension to implement if you need to distinguish 114 | // between the FTP command DELE (remove a file) and RMD (remove a dir). If you don't 115 | // implement this extension they will be both mapped to the Remove method defined in your 116 | // afero.Fs implementation 117 | type ClientDriverExtensionRemoveDir interface { 118 | RemoveDir(name string) error 119 | } 120 | 121 | // ClientDriverExtensionHasher is an extension to implement if you want to handle file digests 122 | // yourself. You have to set EnableHASH to true for this extension to be called 123 | type ClientDriverExtensionHasher interface { 124 | ComputeHash(name string, algo HASHAlgo, startOffset, endOffset int64) (string, error) 125 | } 126 | 127 | // ClientDriverExtensionAvailableSpace is an extension to implement to support 128 | // the AVBL ftp command 129 | type ClientDriverExtensionAvailableSpace interface { 130 | GetAvailableSpace(dirName string) (int64, error) 131 | } 132 | 133 | // ClientContext is implemented on the server side to provide some access to few data around the client 134 | type ClientContext interface { 135 | // Path provides the path of the current connection 136 | Path() string 137 | 138 | // SetPath sets the path of the current connection. 139 | // This method is useful to set a start directory, you should use it before returning a successful 140 | // authentication response from your driver implementation. 141 | // Calling this method after the authentication step could lead to undefined behavior 142 | SetPath(value string) 143 | 144 | // SetListPath allows to change the path for the last LIST/NLST request. 145 | // This method is useful if the driver expands wildcards and so the returned results 146 | // refer to a path different from the requested one. 147 | // The value must be cleaned using path.Clean 148 | SetListPath(value string) 149 | 150 | // SetDebug activates the debugging of this connection commands 151 | SetDebug(debug bool) 152 | 153 | // Debug returns the current debugging status of this connection commands 154 | Debug() bool 155 | 156 | // Client's ID on the server 157 | ID() uint32 158 | 159 | // Client's address 160 | RemoteAddr() net.Addr 161 | 162 | // Servers's address 163 | LocalAddr() net.Addr 164 | 165 | // Client's version can be empty 166 | GetClientVersion() string 167 | 168 | // Close closes the connection and disconnects the client. 169 | Close() error 170 | 171 | // HasTLSForControl returns true if the control connection is over TLS 172 | HasTLSForControl() bool 173 | 174 | // HasTLSForTransfers returns true if the transfer connection is over TLS 175 | HasTLSForTransfers() bool 176 | 177 | // GetLastCommand returns the last received command 178 | GetLastCommand() string 179 | 180 | // GetLastDataChannel returns the last data channel mode 181 | GetLastDataChannel() DataChannel 182 | 183 | // SetTLSRequirement sets the TLS requirement to respect on a per-client basis. 184 | // The requirement is checked when the client issues the "USER" command, 185 | // after executing the MainDriverExtensionUserVerifier extension, and 186 | // before opening transfer connections. 187 | // Supported values: ClearOrEncrypted, MandatoryEncryption. 188 | // If you want to enforce the same requirement for all 189 | // clients, use the TLSRequired parameter defined in server settings instead 190 | SetTLSRequirement(requirement TLSRequirement) error 191 | 192 | // SetExtra allows to set application specific data 193 | SetExtra(extra any) 194 | 195 | // Extra returns application specific data set using SetExtra 196 | Extra() any 197 | } 198 | 199 | // FileTransfer defines the inferface for file transfers. 200 | type FileTransfer interface { 201 | io.Reader 202 | io.Writer 203 | io.Seeker 204 | io.Closer 205 | } 206 | 207 | // FileTransferError is a FileTransfer extension used to notify errors. 208 | type FileTransferError interface { 209 | TransferError(err error) 210 | } 211 | 212 | // PortRange is a range of ports 213 | type PortRange struct { 214 | Start int // Range start 215 | End int // Range end 216 | } 217 | 218 | // PublicIPResolver takes a ClientContext for a connection and returns the public IP 219 | // to use in the response to the PASV command, or an error if a public IP cannot be determined. 220 | type PublicIPResolver func(ClientContext) (string, error) 221 | 222 | // TLSRequirement is the enumerable that represents the supported TLS mode 223 | type TLSRequirement int8 224 | 225 | // TLS modes 226 | const ( 227 | ClearOrEncrypted TLSRequirement = iota 228 | MandatoryEncryption 229 | ImplicitEncryption 230 | ) 231 | 232 | // DataConnectionRequirement is the enumerable that represents the supported 233 | // protection mode for data channels 234 | type DataConnectionRequirement int8 235 | 236 | // Supported data connection requirements 237 | const ( 238 | // IPMatchRequired requires matching peer IP addresses of control and data connection 239 | IPMatchRequired DataConnectionRequirement = iota 240 | // IPMatchDisabled disables checking peer IP addresses of control and data connection 241 | IPMatchDisabled 242 | ) 243 | 244 | // Settings defines all the server settings 245 | // 246 | //nolint:maligned 247 | type Settings struct { 248 | Listener net.Listener // (Optional) To provide an already initialized listener 249 | ListenAddr string // Listening address 250 | PublicHost string // Public IP to expose (only an IP address is accepted at this stage) 251 | PublicIPResolver PublicIPResolver // (Optional) To fetch a public IP lookup 252 | PassiveTransferPortRange *PortRange // (Optional) Port Range for data connections. Random if not specified 253 | ActiveTransferPortNon20 bool // Do not impose the port 20 for active data transfer (#88, RFC 1579) 254 | IdleTimeout int // Maximum inactivity time before disconnecting (#58) 255 | ConnectionTimeout int // Maximum time to establish passive or active transfer connections 256 | DisableMLSD bool // Disable MLSD support 257 | DisableMLST bool // Disable MLST support 258 | DisableMFMT bool // Disable MFMT support (modify file mtime) 259 | Banner string // Banner to use in server status response 260 | TLSRequired TLSRequirement // defines the TLS mode 261 | DisableLISTArgs bool // Disable ls like options (-a,-la etc.) for directory listing 262 | DisableSite bool // Disable SITE command 263 | DisableActiveMode bool // Disable Active FTP 264 | EnableHASH bool // Enable support for calculating hash value of files 265 | DisableSTAT bool // Disable Server STATUS, STAT on files and directories will still work 266 | DisableSYST bool // Disable SYST 267 | EnableCOMB bool // Enable COMB support 268 | DefaultTransferType TransferType // Transfer type to use if the client don't send the TYPE command 269 | // ActiveConnectionsCheck defines the security requirements for active connections 270 | ActiveConnectionsCheck DataConnectionRequirement 271 | // PasvConnectionsCheck defines the security requirements for passive connections 272 | PasvConnectionsCheck DataConnectionRequirement 273 | } 274 | -------------------------------------------------------------------------------- /driver_test.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "io" 7 | "net" 8 | "os" 9 | "strings" 10 | "sync" 11 | "testing" 12 | "time" 13 | 14 | log "github.com/fclairamb/go-log" 15 | "github.com/fclairamb/go-log/gokit" 16 | gklog "github.com/go-kit/log" 17 | "github.com/spf13/afero" 18 | ) 19 | 20 | type tlsVerificationReply int8 21 | 22 | const ( 23 | // tls certificate is ok but a password is required too 24 | tlsVerificationOK tlsVerificationReply = iota 25 | // tls certificate verification failed, the client will be disconnected 26 | tlsVerificationFailed 27 | // tls certificate is ok and no password is required 28 | tlsVerificationAuthenticated 29 | ) 30 | 31 | const ( 32 | authUser = "test" 33 | authPass = "test" 34 | authUserID = 1000 35 | authGroupID = 500 36 | ) 37 | 38 | var errInvalidTLSCertificate = errors.New("invalid TLS certificate") 39 | 40 | // NewTestServer provides a test server with or without debugging 41 | func NewTestServer(t *testing.T, debug bool) *FtpServer { 42 | t.Helper() 43 | 44 | return NewTestServerWithTestDriver(t, &TestServerDriver{Debug: debug}) 45 | } 46 | 47 | func (driver *TestServerDriver) Init() { 48 | if driver.Settings == nil { 49 | driver.Settings = &Settings{ 50 | DefaultTransferType: TransferTypeBinary, 51 | } 52 | } 53 | 54 | if driver.Settings.ListenAddr == "" { 55 | driver.Settings.ListenAddr = "127.0.0.1:0" 56 | } 57 | 58 | { 59 | dir, _ := os.MkdirTemp("", "example") 60 | if err := os.MkdirAll(dir, 0o750); err != nil { 61 | panic(err) 62 | } 63 | 64 | driver.fs = afero.NewBasePathFs(afero.NewOsFs(), dir) 65 | } 66 | } 67 | 68 | func NewTestServerWithTestDriver(t *testing.T, driver *TestServerDriver) *FtpServer { 69 | t.Helper() 70 | 71 | driver.Init() 72 | 73 | // If we are in debug mode, we should log things 74 | var logger log.Logger 75 | if driver.Debug { 76 | logger = gokit.NewWrap(gklog.NewLogfmtLogger(gklog.NewSyncWriter(os.Stdout))).With( 77 | "ts", gokit.GKDefaultTimestampUTC, 78 | "caller", gokit.GKDefaultCaller, 79 | ) 80 | } else { 81 | logger = nil 82 | } 83 | 84 | s := NewTestServerWithDriverAndLogger(t, driver, logger) 85 | 86 | return s 87 | } 88 | 89 | // NewTestServerWithTestDriver provides a server instantiated with some settings 90 | func NewTestServerWithDriverAndLogger(t *testing.T, driver MainDriver, logger log.Logger) *FtpServer { 91 | t.Helper() 92 | 93 | server := NewFtpServer(driver) 94 | 95 | if logger != nil { 96 | server.Logger = logger 97 | } 98 | 99 | if err := server.Listen(); err != nil { 100 | return nil 101 | } 102 | 103 | t.Cleanup(func() { 104 | mustStopServer(server) 105 | }) 106 | 107 | go func() { 108 | if err := server.Serve(); err != nil && !errors.Is(err, io.EOF) { 109 | server.Logger.Error("problem serving", "err", err) 110 | } 111 | }() 112 | 113 | return server 114 | } 115 | 116 | func NewTestServerWithDriver(t *testing.T, driver MainDriver) *FtpServer { 117 | t.Helper() 118 | 119 | return NewTestServerWithDriverAndLogger(t, driver, nil) 120 | } 121 | 122 | // TestServerDriver defines a minimal serverftp server driver 123 | type TestServerDriver struct { 124 | Debug bool // To display connection logs information 125 | TLS bool 126 | CloseOnConnect bool // disconnect the client as soon as it connects 127 | 128 | Settings *Settings // Settings 129 | fs afero.Fs 130 | clientMU sync.Mutex 131 | Clients []ClientContext 132 | TLSVerificationReply tlsVerificationReply 133 | errPassiveListener error 134 | TLSRequirement TLSRequirement 135 | } 136 | 137 | // TestClientDriver defines a minimal serverftp client driver 138 | type TestClientDriver struct { 139 | afero.Fs 140 | } 141 | 142 | type testFile struct { 143 | afero.File 144 | errTransfer error 145 | } 146 | 147 | var ( 148 | errFailClose = errors.New("couldn't close") 149 | errFailWrite = errors.New("couldn't write") 150 | errFailSeek = errors.New("couldn't seek") 151 | errFailReaddir = errors.New("couldn't readdir") 152 | errFailOpen = errors.New("couldn't open") 153 | ) 154 | 155 | func (f *testFile) Read(out []byte) (int, error) { 156 | // simulating a slow reading allows us to test ABOR 157 | if strings.Contains(f.File.Name(), "delay-io") { 158 | time.Sleep(500 * time.Millisecond) 159 | } 160 | 161 | return f.File.Read(out) 162 | } 163 | 164 | func (f *testFile) Write(out []byte) (int, error) { 165 | if strings.Contains(f.File.Name(), "fail-to-write") { 166 | return 0, errFailWrite 167 | } 168 | 169 | // simulating a slow writing allows us to test ABOR 170 | if strings.Contains(f.File.Name(), "delay-io") { 171 | time.Sleep(500 * time.Millisecond) 172 | } 173 | 174 | return f.File.Write(out) 175 | } 176 | 177 | func (f *testFile) Close() error { 178 | if strings.Contains(f.File.Name(), "fail-to-close") { 179 | return errFailClose 180 | } 181 | 182 | return f.File.Close() 183 | } 184 | 185 | func (f *testFile) Seek(offset int64, whence int) (int64, error) { 186 | // by delaying the seek and sending a REST before the actual transfer 187 | // we can delay the opening of the transfer and then test an ABOR before 188 | // opening a transfer. I'm not sure if this can really happen but it is 189 | // better to be prepared for buggy clients too 190 | if strings.Contains(f.File.Name(), "delay-io") { 191 | time.Sleep(500 * time.Millisecond) 192 | } 193 | 194 | if strings.Contains(f.File.Name(), "fail-to-seek") { 195 | return 0, errFailSeek 196 | } 197 | 198 | return f.File.Seek(offset, whence) 199 | } 200 | 201 | func (f *testFile) Readdir(count int) ([]os.FileInfo, error) { 202 | if strings.Contains(f.File.Name(), "delay-io") { 203 | time.Sleep(500 * time.Millisecond) 204 | } 205 | 206 | if strings.Contains(f.File.Name(), "fail-to-readdir") { 207 | return nil, errFailReaddir 208 | } 209 | 210 | return f.File.Readdir(count) 211 | } 212 | 213 | // TransferError implements the FileTransferError interface 214 | func (f *testFile) TransferError(err error) { 215 | f.errTransfer = err 216 | } 217 | 218 | // NewTestClientDriver creates a client driver 219 | func NewTestClientDriver(server *TestServerDriver) *TestClientDriver { 220 | return &TestClientDriver{ 221 | Fs: server.fs, 222 | } 223 | } 224 | 225 | func mustStopServer(server *FtpServer) { 226 | err := server.Stop() 227 | if err != nil { 228 | panic(err) 229 | } 230 | } 231 | 232 | var errConnectionNotAllowed = errors.New("connection not allowed") 233 | 234 | // ClientConnected is the very first message people will see 235 | func (driver *TestServerDriver) ClientConnected(cltContext ClientContext) (string, error) { 236 | driver.clientMU.Lock() 237 | defer driver.clientMU.Unlock() 238 | 239 | var err error 240 | 241 | if driver.CloseOnConnect { 242 | err = errConnectionNotAllowed 243 | } 244 | 245 | cltContext.SetDebug(driver.Debug) 246 | // we set the client id as extra data just for testing 247 | cltContext.SetExtra(cltContext.ID()) 248 | driver.Clients = append(driver.Clients, cltContext) 249 | // This will remain the official name for now 250 | return "TEST Server", err 251 | } 252 | 253 | var errBadUserNameOrPassword = errors.New("bad username or password") 254 | 255 | // AuthUser with authenticate users 256 | func (driver *TestServerDriver) AuthUser(_ ClientContext, user, pass string) (ClientDriver, error) { 257 | if user == authUser && pass == authPass { 258 | clientdriver := NewTestClientDriver(driver) 259 | 260 | return clientdriver, nil 261 | } else if user == "nil" && pass == "nil" { 262 | // Definitely a bad behavior (but can be done on the driver side) 263 | return nil, nil //nolint:nilnil 264 | } 265 | 266 | return nil, errBadUserNameOrPassword 267 | } 268 | 269 | type MesssageDriver struct { 270 | TestServerDriver 271 | } 272 | 273 | // PostAuthMessage returns a message displayed after authentication 274 | func (driver *MesssageDriver) PostAuthMessage(_ ClientContext, _ string, authErr error) string { 275 | if authErr != nil { 276 | return "You are not welcome here" 277 | } 278 | 279 | return "Welcome to the FTP Server" 280 | } 281 | 282 | // QuitMessage returns a goodbye message 283 | func (driver *MesssageDriver) QuitMessage() string { 284 | return "Sayonara, bye bye!" 285 | } 286 | 287 | // ClientDisconnected is called when the user disconnects 288 | func (driver *TestServerDriver) ClientDisconnected(cc ClientContext) { 289 | driver.clientMU.Lock() 290 | defer driver.clientMU.Unlock() 291 | 292 | for idx, client := range driver.Clients { 293 | if client.ID() == cc.ID() { 294 | lastIdx := len(driver.Clients) - 1 295 | driver.Clients[idx] = driver.Clients[lastIdx] 296 | driver.Clients[lastIdx] = nil 297 | driver.Clients = driver.Clients[:lastIdx] 298 | 299 | return 300 | } 301 | } 302 | } 303 | 304 | // GetClientsInfo returns info about the connected clients 305 | func (driver *TestServerDriver) GetClientsInfo() map[uint32]interface{} { 306 | driver.clientMU.Lock() 307 | defer driver.clientMU.Unlock() 308 | 309 | info := make(map[uint32]interface{}) 310 | 311 | for _, clientContext := range driver.Clients { 312 | ccInfo := make(map[string]interface{}) 313 | 314 | ccInfo["localAddr"] = clientContext.LocalAddr() 315 | ccInfo["remoteAddr"] = clientContext.RemoteAddr() 316 | ccInfo["clientVersion"] = clientContext.GetClientVersion() 317 | ccInfo["path"] = clientContext.Path() 318 | ccInfo["hasTLSForControl"] = clientContext.HasTLSForControl() 319 | ccInfo["hasTLSForTransfers"] = clientContext.HasTLSForTransfers() 320 | ccInfo["lastCommand"] = clientContext.GetLastCommand() 321 | ccInfo["debug"] = clientContext.Debug() 322 | ccInfo["extra"] = clientContext.Extra() 323 | 324 | info[clientContext.ID()] = ccInfo 325 | } 326 | 327 | return info 328 | } 329 | 330 | var errNoClientConnected = errors.New("no client connected") 331 | 332 | // DisconnectClient disconnect one of the connected clients 333 | func (driver *TestServerDriver) DisconnectClient() error { 334 | driver.clientMU.Lock() 335 | defer driver.clientMU.Unlock() 336 | 337 | if len(driver.Clients) > 0 { 338 | return driver.Clients[0].Close() 339 | } 340 | 341 | return errNoClientConnected 342 | } 343 | 344 | // GetSettings fetches the basic server settings 345 | func (driver *TestServerDriver) GetSettings() (*Settings, error) { 346 | return driver.Settings, nil 347 | } 348 | 349 | var errNoTLS = errors.New("TLS is not configured") 350 | 351 | // GetTLSConfig fetches the TLS config 352 | func (driver *TestServerDriver) GetTLSConfig() (*tls.Config, error) { 353 | if driver.TLS { 354 | keypair, err := tls.X509KeyPair(localhostCert, localhostKey) 355 | if err != nil { 356 | return nil, err 357 | } 358 | 359 | return &tls.Config{ 360 | MinVersion: tls.VersionTLS12, 361 | Certificates: []tls.Certificate{keypair}, 362 | }, nil 363 | } 364 | 365 | return nil, errNoTLS 366 | } 367 | 368 | func (driver *TestServerDriver) PreAuthUser(cc ClientContext, _ string) error { 369 | return cc.SetTLSRequirement(driver.TLSRequirement) 370 | } 371 | 372 | func (driver *TestServerDriver) VerifyConnection(_ ClientContext, _ string, 373 | _ *tls.Conn, 374 | ) (ClientDriver, error) { 375 | switch driver.TLSVerificationReply { 376 | case tlsVerificationFailed: 377 | return nil, errInvalidTLSCertificate 378 | case tlsVerificationAuthenticated: 379 | clientdriver := NewTestClientDriver(driver) 380 | 381 | return clientdriver, nil 382 | case tlsVerificationOK: 383 | return nil, nil //nolint:nilnil 384 | } 385 | 386 | return nil, nil //nolint:nilnil 387 | } 388 | 389 | func (driver *TestServerDriver) WrapPassiveListener(listener net.Listener) (net.Listener, error) { 390 | if driver.errPassiveListener != nil { 391 | return nil, driver.errPassiveListener 392 | } 393 | 394 | return listener, nil 395 | } 396 | 397 | // OpenFile opens a file in 3 possible modes: read, write, appending write (use appropriate flags) 398 | func (driver *TestClientDriver) OpenFile(path string, flag int, perm os.FileMode) (afero.File, error) { 399 | if strings.Contains(path, "fail-to-open") { 400 | return nil, errFailOpen 401 | } 402 | 403 | if strings.Contains(path, "quota-exceeded") { 404 | return nil, ErrStorageExceeded 405 | } 406 | 407 | if strings.Contains(path, "not-allowed") { 408 | return nil, ErrFileNameNotAllowed 409 | } 410 | 411 | file, err := driver.Fs.OpenFile(path, flag, perm) 412 | 413 | if err == nil { 414 | file = &testFile{File: file} 415 | } 416 | 417 | return file, err 418 | } 419 | 420 | func (driver *TestClientDriver) Open(name string) (afero.File, error) { 421 | if strings.Contains(name, "fail-to-open") { 422 | return nil, errFailOpen 423 | } 424 | 425 | file, err := driver.Fs.Open(name) 426 | 427 | if err == nil { 428 | file = &testFile{File: file} 429 | } 430 | 431 | return file, err 432 | } 433 | 434 | func (driver *TestClientDriver) Rename(oldname, newname string) error { 435 | if strings.Contains(newname, "not-allowed") { 436 | return ErrFileNameNotAllowed 437 | } 438 | 439 | return driver.Fs.Rename(oldname, newname) 440 | } 441 | 442 | var errTooMuchSpaceRequested = errors.New("you're requesting too much space") 443 | 444 | func (driver *TestClientDriver) AllocateSpace(size int) error { 445 | if size < 1*1024*1024 { 446 | return nil 447 | } 448 | 449 | return errTooMuchSpaceRequested 450 | } 451 | 452 | var errAvblNotPermitted = errors.New("you're not allowed to request available space for this directory") 453 | 454 | func (driver *TestClientDriver) GetAvailableSpace(dirName string) (int64, error) { 455 | if dirName == "/noavbl" { 456 | return 0, errAvblNotPermitted 457 | } 458 | 459 | return int64(123), nil 460 | } 461 | 462 | var ( 463 | errInvalidChownUser = errors.New("invalid chown on user") 464 | errInvalidChownGroup = errors.New("invalid chown on group") 465 | ) 466 | 467 | func (driver *TestClientDriver) Chown(name string, uid int, gid int) error { 468 | if uid != 0 && uid != authUserID { 469 | return errInvalidChownUser 470 | } 471 | 472 | if gid != 0 && gid != authGroupID { 473 | return errInvalidChownGroup 474 | } 475 | 476 | _, err := driver.Fs.Stat(name) 477 | 478 | return err 479 | } 480 | 481 | var errSymlinkNotImplemented = errors.New("symlink not implemented") 482 | 483 | func (driver *TestClientDriver) Symlink(oldname, newname string) error { 484 | if linker, ok := driver.Fs.(afero.Linker); ok { 485 | return linker.SymlinkIfPossible(oldname, newname) 486 | } 487 | 488 | return errSymlinkNotImplemented 489 | } 490 | 491 | // (copied from net/http/httptest) 492 | // localhostCert is a PEM-encoded TLS cert with SAN IPs 493 | // "127.0.0.1" and "[::1]", expiring at the last second of 2049 (the end 494 | // of ASN.1 time). 495 | // generated from src/crypto/tls: 496 | // 497 | // go run "$(go env GOROOT)/src/crypto/tls/generate_cert.go" \ 498 | // --rsa-bits 2048 \ 499 | // --host 127.0.0.1,::1,example.com \ 500 | // --ca --start-date "Jan 1 00:00:00 1970" \ 501 | // --duration=1000000h 502 | // 503 | // The initial 512 bits key caused this error: 504 | // "tls: failed to sign handshake: crypto/rsa: key size too small for PSS signature" 505 | var localhostCert = []byte(`-----BEGIN CERTIFICATE----- 506 | MIIDGTCCAgGgAwIBAgIRAJ5VaFcqzaSMmEpeZc33uuowDQYJKoZIhvcNAQELBQAw 507 | EjEQMA4GA1UEChMHQWNtZSBDbzAgFw03MDAxMDEwMDAwMDBaGA8yMDg0MDEyOTE2 508 | MDAwMFowEjEQMA4GA1UEChMHQWNtZSBDbzCCASIwDQYJKoZIhvcNAQEBBQADggEP 509 | ADCCAQoCggEBAMmv1cldip1/97VnNpPElc5Msa69Cx9l2LmtPubok3pcQy4lS/uF 510 | 1zlMDwFseRYuYMOy+lafmYsCO1OFQvt4dginlSZ9yUNq7qSv+dvOUpn6bWQdrLto 511 | d+fDWS4KWiiFsyS78ITozMyRS3G9mJS8YSbGuV4O50UpOJQd6yN5pMQEnp/wHfRI 512 | 6y9OYOjWe2snw3rXq1wN7wkj4iVKgrqkJJUHe7Heq4uD7uGfABfOyACmzYFxexXN 513 | f+++L/DesKyMH2At+nKmBtF3JixViIyVKpsCz6Lce1P90n39lYuQDHbV/N2P7ww8 514 | fiwH7fA30yfDqxWKUXhxu7eHGxD1GCFBpgcCAwEAAaNoMGYwDgYDVR0PAQH/BAQD 515 | AgKkMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMBAf8wLgYDVR0R 516 | BCcwJYILZXhhbXBsZS5jb22HBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZI 517 | hvcNAQELBQADggEBAHwPqImQQHsqao5MSmhd3S8XQiH6V95T2qOiF9pri7TfMF8h 518 | JWhkaFKQ0yMi37reSZ5+cBiHO9z3UIAmpAWLdqbtVOnh2bchVMO8nSnKHkrOqV2E 519 | IK0Fq5QVW2wyHlYaOMLLQ2sA4I3J/yHl6W4rigetEzY8OtQtPFbg/S1YMqFV8mRz 520 | 8PAxtrOWK+ARJP9tqgbylcL6/6cZc6lBQcs0BuwXjcI6fxi+YBXEqbpah9tGRivD 521 | X/k0l93dx/zfNc1Yz06VrCpko6W2Kqa76F6tDIa+WpfZba7t7jNFZTPB4dymUS9L 522 | ICoGMF9k6xscqAURRx8RoSiELemGE5kYUsyvqSE= 523 | -----END CERTIFICATE-----`) 524 | 525 | // localhostKey is the private key for localhostCert. 526 | var localhostKey = []byte(`-----BEGIN PRIVATE KEY----- 527 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJr9XJXYqdf/e1 528 | ZzaTxJXOTLGuvQsfZdi5rT7m6JN6XEMuJUv7hdc5TA8BbHkWLmDDsvpWn5mLAjtT 529 | hUL7eHYIp5UmfclDau6kr/nbzlKZ+m1kHay7aHfnw1kuCloohbMku/CE6MzMkUtx 530 | vZiUvGEmxrleDudFKTiUHesjeaTEBJ6f8B30SOsvTmDo1ntrJ8N616tcDe8JI+Il 531 | SoK6pCSVB3ux3quLg+7hnwAXzsgAps2BcXsVzX/vvi/w3rCsjB9gLfpypgbRdyYs 532 | VYiMlSqbAs+i3HtT/dJ9/ZWLkAx21fzdj+8MPH4sB+3wN9Mnw6sVilF4cbu3hxsQ 533 | 9RghQaYHAgMBAAECggEADmJN+viC5Ey2G+fqiotgq7/ohC/TVT/sPwHOFKXNrtJZ 534 | sDbUvnGDMgDsqQtVb3GLUSm4lOj5CGL2XDSK3Ghw8pkRGBesfPRpZLFwPm7ukTC9 535 | EIDVSuBefNb/yzrNx0oRxrLoqnH3+Tb7jHcbJLBytVNC8SRa9iHEeTvRA0yvpZMW 536 | WriTbAELv+Zcjal2fPYYtTE9HnRpJX7kHvnCRzlGza0MIs8Q4QgmBE20GRCEXaRi 537 | 4jPYjlBx/N4mdD1MTz9jAq+WCHQNJS6aic6l5jidemsSDjtLkSIy8mpTSbA83BTe 538 | qkjAbxtSQ5FKYYH6zDhNbGKwmyqaF1g5gMPSFaDjUQKBgQDfsXamsiyf2Er1+WyI 539 | WyFxwRJmDJ8y3IlH5z5d8Gw+b3JFt72Cy6MVYsO564UALQgUwJmxFIjnVh5F/ZA5 540 | nwsfyyfUkpwQtiqZOTzeTnMd1wt4MPmGwaxfGwVhG5fUgYKnt1GTyF4zHz653RoL 541 | AA0hhsiVmd4hb53PfVHEMVPEewKBgQDm0LzTkgz4zYgocRxiqa4n62VngRS2l7vs 542 | oBgf6o7Dssp1aOucM5uryqzOZsAB/BwCVVeVTnC5nCL2os59YFWbBLlt15l7ykBo 543 | HvUwfmf0R+81onMDqjYPj1+9CSKw4BbTD0WMBOUehvMpL6/k9CsAC2jXQ0oH735V 544 | 7dQHEZ1s5QKBgGNbGn1eBE4XLuxkFd3WxFsXS4nCL2/S3rLuNhhZcmqk65elzenr 545 | cwtLq+3He3KhjcZR6bHqkghWiunBfy7ownMjtBRJ7kHJ98/IyY1gQOdPHcwLzLkb 546 | CunPQatpKx37TEIcPYKra5O/XAgH+cpLAooSqMMx7aTiQ7DmU8wVsMRDAoGAQHcM 547 | RgsElHjTDniI9QVvHrcgG0hyAI1gbzZHhqJ8PSwyX5huNbI0SEbS/NK1zdgb+orb 548 | a1f9I9n36eqOwXWmcyVepM8SjwBt/Kao1GJ5pkBxDwnQFbX0Y2Qn2SQ0DDKKLWiW 549 | hATZ+Sy3vUkUV13apKiLH5QrmQvKvTUvgsnorgECgYEAsuf9V7HXDVL3Za1imywP 550 | B8waIgXRIjSWT4Fje7RTMT948qhguVhpoAgVzwzMqizzq6YIQbL7MHwXj7oZNUoQ 551 | CARLpnYLaeWP2nxQyzwGx5pn9TJwg79Yknr8PbSjeym1BSbE5C9ruqar4PfiIzYx 552 | di02m2YJAvRsG9VDpXogi+c= 553 | -----END PRIVATE KEY-----`) 554 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | // ErrStorageExceeded defines the error mapped to the FTP 552 reply code. 10 | // As for RFC 959 this error is checked for STOR, APPE 11 | ErrStorageExceeded = errors.New("storage limit exceeded") 12 | // ErrFileNameNotAllowed defines the error mapped to the FTP 553 reply code. 13 | // As for RFC 959 this error is checked for STOR, APPE, RNTO 14 | ErrFileNameNotAllowed = errors.New("filename not allowed") 15 | ) 16 | 17 | func getErrorCode(err error, defaultCode int) int { 18 | switch { 19 | case errors.Is(err, ErrStorageExceeded): 20 | return StatusActionAborted 21 | case errors.Is(err, ErrFileNameNotAllowed): 22 | return StatusActionNotTakenNoFile 23 | default: 24 | return defaultCode 25 | } 26 | } 27 | 28 | // DriverError is a wrapper is for any error that occur while contacting the drivers 29 | type DriverError struct { 30 | str string 31 | err error 32 | } 33 | 34 | func newDriverError(str string, err error) DriverError { 35 | return DriverError{str: str, err: err} 36 | } 37 | 38 | func (e DriverError) Error() string { 39 | return fmt.Sprintf("driver error: %s: %v", e.str, e.err) 40 | } 41 | 42 | func (e DriverError) Unwrap() error { 43 | return e.err 44 | } 45 | 46 | // NetworkError is a wrapper for any error that occur while contacting the network 47 | type NetworkError struct { 48 | str string 49 | err error 50 | } 51 | 52 | func newNetworkError(str string, err error) NetworkError { 53 | return NetworkError{str: str, err: err} 54 | } 55 | 56 | func (e NetworkError) Error() string { 57 | return fmt.Sprintf("network error: %s: %v", e.str, e.err) 58 | } 59 | 60 | func (e NetworkError) Unwrap() error { 61 | return e.err 62 | } 63 | 64 | // FileAccessError is a wrapper for any error that occur while accessing the file system 65 | type FileAccessError struct { 66 | str string 67 | err error 68 | } 69 | 70 | func newFileAccessError(str string, err error) FileAccessError { 71 | return FileAccessError{str: str, err: err} 72 | } 73 | 74 | func (e FileAccessError) Error() string { 75 | return fmt.Sprintf("file access error: %s: %v", e.str, e.err) 76 | } 77 | 78 | func (e FileAccessError) Unwrap() error { 79 | return e.err 80 | } 81 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestCustomErrorsCode(t *testing.T) { 14 | code := getErrorCode(ErrStorageExceeded, StatusActionNotTaken) 15 | assert.Equal(t, StatusActionAborted, code) 16 | code = getErrorCode(ErrFileNameNotAllowed, StatusActionNotTaken) 17 | assert.Equal(t, StatusActionNotTakenNoFile, code) 18 | code = getErrorCode(os.ErrPermission, StatusActionNotTaken) 19 | assert.Equal(t, StatusActionNotTaken, code) 20 | code = getErrorCode(os.ErrClosed, StatusNotLoggedIn) 21 | assert.Equal(t, StatusNotLoggedIn, code) 22 | } 23 | 24 | func TestTransferCloseStorageExceeded(t *testing.T) { 25 | buf := bytes.Buffer{} 26 | h := clientHandler{writer: bufio.NewWriter(&buf)} 27 | h.TransferClose(ErrStorageExceeded) 28 | require.Equal(t, "552 Issue during transfer: storage limit exceeded\r\n", buf.String()) 29 | } 30 | 31 | func TestErrorTypes(t *testing.T) { 32 | // a := assert.New(t) 33 | t.Run("DriverError", func(t *testing.T) { 34 | req := require.New(t) 35 | var err error = newDriverError("test", os.ErrPermission) 36 | req.Equal("driver error: test: permission denied", err.Error()) 37 | req.ErrorIs(err, os.ErrPermission) 38 | 39 | var specificError DriverError 40 | req.ErrorAs(err, &specificError) 41 | req.Equal("test", specificError.str) 42 | }) 43 | 44 | t.Run("NetworkError", func(t *testing.T) { 45 | req := require.New(t) 46 | var err error = newNetworkError("test", os.ErrPermission) 47 | req.Equal("network error: test: permission denied", err.Error()) 48 | req.ErrorIs(err, os.ErrPermission) 49 | 50 | var specificError NetworkError 51 | req.ErrorAs(err, &specificError) 52 | req.Equal("test", specificError.str) 53 | }) 54 | 55 | t.Run("FileAccessError", func(t *testing.T) { 56 | req := require.New(t) 57 | var err error = newFileAccessError("test", os.ErrPermission) 58 | req.Equal("file access error: test: permission denied", err.Error()) 59 | req.ErrorIs(err, os.ErrPermission) 60 | var specificError FileAccessError 61 | req.ErrorAs(err, &specificError) 62 | req.Equal("test", specificError.str) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fclairamb/ftpserverlib 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/fclairamb/go-log v0.5.0 9 | github.com/go-kit/log v0.2.1 10 | github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 11 | github.com/spf13/afero v1.14.0 12 | github.com/stretchr/testify v1.10.0 13 | golang.org/x/sys v0.33.0 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/go-logfmt/logfmt v0.5.1 // indirect 19 | github.com/pmezard/go-difflib v1.0.0 // indirect 20 | golang.org/x/text v0.23.0 // indirect 21 | gopkg.in/yaml.v3 v3.0.1 // indirect 22 | ) 23 | 24 | replace github.com/secsy/goftp => github.com/drakkan/goftp v0.0.0-20201220151643-27b7174e8caf 25 | -------------------------------------------------------------------------------- /handle_auth.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | ) 7 | 8 | // Handle the "USER" command 9 | func (c *clientHandler) handleUSER(user string) error { 10 | if verifier, ok := c.server.driver.(MainDriverExtensionUserVerifier); ok { 11 | err := verifier.PreAuthUser(c, user) 12 | if err != nil { 13 | c.writeMessage(StatusNotLoggedIn, fmt.Sprintf("User rejected: %v", err)) 14 | c.disconnect() 15 | 16 | return nil 17 | } 18 | } 19 | 20 | if c.isTLSRequired() && !c.HasTLSForControl() { 21 | c.writeMessage(StatusServiceNotAvailable, "TLS is required") 22 | c.disconnect() 23 | 24 | return nil 25 | } 26 | 27 | if c.HasTLSForControl() { 28 | if c.handleUserTLS(user) { 29 | return nil 30 | } 31 | } 32 | 33 | c.user = user 34 | c.writeMessage(StatusUserOK, "OK") 35 | 36 | return nil 37 | } 38 | 39 | func (c *clientHandler) handleUserTLS(user string) bool { 40 | verifier, interfaceFound := c.server.driver.(MainDriverExtensionTLSVerifier) 41 | 42 | if !interfaceFound { 43 | return false 44 | } 45 | 46 | tlsConn, interfaceFound := c.conn.(*tls.Conn) 47 | 48 | if !interfaceFound { 49 | return false 50 | } 51 | 52 | driver, err := verifier.VerifyConnection(c, user, tlsConn) 53 | if err != nil { 54 | c.writeMessage(StatusNotLoggedIn, fmt.Sprintf("TLS verification failed: %v", err)) 55 | c.disconnect() 56 | 57 | return true 58 | } 59 | 60 | if driver != nil { 61 | c.user = user 62 | c.driver = driver 63 | c.writeMessage(StatusUserLoggedIn, "TLS certificate ok, continue") 64 | 65 | return true 66 | } 67 | 68 | return false 69 | } 70 | 71 | // Handle the "PASS" command 72 | func (c *clientHandler) handlePASS(param string) error { 73 | var err error 74 | var msg string 75 | c.driver, err = c.server.driver.AuthUser(c, c.user, param) 76 | 77 | dpa, ok := c.server.driver.(MainDriverExtensionPostAuthMessage) 78 | if ok { 79 | msg = dpa.PostAuthMessage(c, c.user, err) 80 | } 81 | 82 | switch { 83 | case err == nil && c.driver == nil: 84 | c.writeMessage(StatusNotLoggedIn, "Unexpected exception (driver is nil)") 85 | c.disconnect() 86 | case err != nil: 87 | if msg == "" { 88 | msg = fmt.Sprintf("Authentication error: %v", err) 89 | } 90 | 91 | c.writeMessage(StatusNotLoggedIn, msg) 92 | c.disconnect() 93 | default: // err == nil && c.driver != nil 94 | if msg == "" { 95 | msg = "Password ok, continue" 96 | } 97 | 98 | c.writeMessage(StatusUserLoggedIn, msg) 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /handle_auth_test.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "crypto/tls" 5 | "net" 6 | "testing" 7 | "time" 8 | 9 | "github.com/secsy/goftp" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func panicOnError(err error) { 14 | if err != nil { 15 | panic(err) 16 | } 17 | } 18 | 19 | func TestLoginSuccess(t *testing.T) { 20 | server := NewTestServer(t, false) 21 | // send a NOOP before the login, this doesn't seems possible using secsy/goftp so use the old way ... 22 | conn, err := net.DialTimeout("tcp", server.Addr(), 5*time.Second) 23 | require.NoError(t, err) 24 | 25 | defer func() { 26 | err = conn.Close() 27 | require.NoError(t, err) 28 | }() 29 | 30 | buf := make([]byte, 1024) 31 | readBytes, err := conn.Read(buf) 32 | require.NoError(t, err) 33 | 34 | response := string(buf[:readBytes]) 35 | require.Equal(t, "220 TEST Server\r\n", response) 36 | 37 | _, err = conn.Write([]byte("NOOP\r\n")) 38 | require.NoError(t, err) 39 | 40 | readBytes, err = conn.Read(buf) 41 | require.NoError(t, err) 42 | 43 | response = string(buf[:readBytes]) 44 | require.Equal(t, "200 OK\r\n", response) 45 | 46 | conf := goftp.Config{ 47 | User: authUser, 48 | Password: authPass, 49 | } 50 | 51 | c, err := goftp.DialConfig(conf, server.Addr()) 52 | require.NoError(t, err, "Couldn't connect") 53 | 54 | defer func() { panicOnError(c.Close()) }() 55 | 56 | raw, err := c.OpenRawConn() 57 | require.NoError(t, err, "Couldn't open raw connection") 58 | 59 | defer func() { require.NoError(t, raw.Close()) }() 60 | 61 | returnCode, _, err := raw.SendCommand("NOOP") 62 | require.NoError(t, err) 63 | require.Equal(t, StatusOK, returnCode, "Couldn't NOOP") 64 | 65 | returnCode, response, err = raw.SendCommand("SYST") 66 | require.NoError(t, err) 67 | require.Equal(t, StatusSystemType, returnCode) 68 | require.Equal(t, "UNIX Type: L8", response) 69 | 70 | server.settings.DisableSYST = true 71 | returnCode, response, err = raw.SendCommand("SYST") 72 | require.NoError(t, err) 73 | require.Equal(t, StatusCommandNotImplemented, returnCode, response) 74 | } 75 | 76 | func TestLoginFailure(t *testing.T) { 77 | server := NewTestServer(t, false) 78 | 79 | conf := goftp.Config{ 80 | User: authUser, 81 | Password: authPass + "_wrong", 82 | } 83 | 84 | client, err := goftp.DialConfig(conf, server.Addr()) 85 | require.NoError(t, err, "Couldn't connect") 86 | 87 | defer func() { panicOnError(client.Close()) }() 88 | 89 | _, err = client.OpenRawConn() 90 | require.Error(t, err, "We should have failed to login") 91 | } 92 | 93 | func TestLoginCustom(t *testing.T) { 94 | req := require.New(t) 95 | driver := &MesssageDriver{} 96 | driver.Init() 97 | server := NewTestServerWithDriver(t, driver) 98 | 99 | conf := goftp.Config{ 100 | User: authUser, 101 | Password: authPass + "_wrong", 102 | } 103 | 104 | client, err := goftp.DialConfig(conf, server.Addr()) 105 | req.NoError(err, "Couldn't connect") 106 | 107 | defer func() { panicOnError(client.Close()) }() 108 | 109 | _, err = client.OpenRawConn() 110 | req.Error(err, "We should have failed to login") 111 | } 112 | 113 | func TestLoginNil(t *testing.T) { 114 | server := NewTestServer(t, true) 115 | req := require.New(t) 116 | 117 | conf := goftp.Config{ 118 | User: "nil", 119 | Password: "nil", 120 | } 121 | 122 | client, err := goftp.DialConfig(conf, server.Addr()) 123 | require.NoError(t, err, "Couldn't connect") 124 | 125 | defer func() { panicOnError(client.Close()) }() 126 | 127 | _, err = client.OpenRawConn() 128 | req.Error(err) 129 | } 130 | 131 | func TestAuthTLS(t *testing.T) { 132 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 133 | Debug: false, 134 | TLS: true, 135 | }) 136 | 137 | conf := goftp.Config{ 138 | User: authUser, 139 | Password: authPass, 140 | TLSConfig: &tls.Config{ 141 | //nolint:gosec 142 | InsecureSkipVerify: true, 143 | }, 144 | TLSMode: goftp.TLSExplicit, 145 | } 146 | 147 | client, err := goftp.DialConfig(conf, server.Addr()) 148 | require.NoError(t, err, "Couldn't connect") 149 | 150 | defer func() { panicOnError(client.Close()) }() 151 | 152 | raw, err := client.OpenRawConn() 153 | require.NoError(t, err, "Couldn't upgrade connection to TLS") 154 | 155 | err = raw.Close() 156 | require.NoError(t, err) 157 | } 158 | 159 | func TestAuthExplicitTLSFailure(t *testing.T) { 160 | server := NewTestServer(t, false) 161 | 162 | conf := goftp.Config{ 163 | User: authUser, 164 | Password: authPass, 165 | TLSConfig: &tls.Config{ 166 | //nolint:gosec 167 | InsecureSkipVerify: true, 168 | }, 169 | TLSMode: goftp.TLSExplicit, 170 | } 171 | 172 | c, err := goftp.DialConfig(conf, server.Addr()) 173 | require.NoError(t, err, "Couldn't connect") 174 | 175 | defer func() { panicOnError(c.Close()) }() 176 | 177 | _, err = c.OpenRawConn() 178 | require.Error(t, err, "Upgrade to TLS should fail, TLS is not configured server side") 179 | } 180 | 181 | func TestAuthTLSRequired(t *testing.T) { 182 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 183 | Debug: false, 184 | TLS: true, 185 | }) 186 | server.settings.TLSRequired = MandatoryEncryption 187 | 188 | conf := goftp.Config{ 189 | User: authUser, 190 | Password: authPass, 191 | } 192 | 193 | client, err := goftp.DialConfig(conf, server.Addr()) 194 | require.NoError(t, err, "Couldn't connect") 195 | 196 | defer func() { panicOnError(client.Close()) }() 197 | 198 | _, err = client.OpenRawConn() 199 | require.Error(t, err, "Plain text login must fail, TLS is required") 200 | require.EqualError(t, err, "unexpected response: 421-TLS is required") 201 | 202 | conf.TLSConfig = &tls.Config{ 203 | //nolint:gosec 204 | InsecureSkipVerify: true, 205 | } 206 | conf.TLSMode = goftp.TLSExplicit 207 | 208 | client, err = goftp.DialConfig(conf, server.Addr()) 209 | require.NoError(t, err, "Couldn't connect") 210 | 211 | raw, err := client.OpenRawConn() 212 | require.NoError(t, err, "Couldn't open raw connection") 213 | 214 | defer func() { require.NoError(t, raw.Close()) }() 215 | 216 | rc, _, err := raw.SendCommand("STAT") 217 | require.NoError(t, err) 218 | require.Equal(t, StatusSystemStatus, rc) 219 | } 220 | 221 | func TestAuthTLSVerificationFailed(t *testing.T) { 222 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 223 | Debug: true, 224 | TLS: true, 225 | TLSVerificationReply: tlsVerificationFailed, 226 | }) 227 | 228 | conf := goftp.Config{ 229 | User: authUser, 230 | Password: authPass, 231 | TLSConfig: &tls.Config{ 232 | //nolint:gosec 233 | InsecureSkipVerify: true, 234 | }, 235 | TLSMode: goftp.TLSExplicit, 236 | } 237 | 238 | client, err := goftp.DialConfig(conf, server.Addr()) 239 | require.NoError(t, err, "Couldn't connect") 240 | 241 | defer func() { panicOnError(client.Close()) }() 242 | 243 | _, err = client.OpenRawConn() 244 | require.Error(t, err, "TLS verification shoul fail") 245 | } 246 | 247 | func TestAuthTLSCertificate(t *testing.T) { 248 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 249 | Debug: true, 250 | TLS: true, 251 | TLSVerificationReply: tlsVerificationAuthenticated, 252 | }) 253 | 254 | conf := goftp.Config{ 255 | User: authUser, 256 | TLSConfig: &tls.Config{ 257 | //nolint:gosec 258 | InsecureSkipVerify: true, 259 | }, 260 | TLSMode: goftp.TLSExplicit, 261 | } 262 | 263 | c, err := goftp.DialConfig(conf, server.Addr()) 264 | require.NoError(t, err, "Couldn't connect") 265 | 266 | defer func() { panicOnError(c.Close()) }() 267 | 268 | raw, err := c.OpenRawConn() 269 | require.NoError(t, err, "Couldn't open raw connection") 270 | 271 | defer func() { require.NoError(t, raw.Close()) }() 272 | 273 | rc, _, err := raw.SendCommand("STAT") 274 | require.NoError(t, err) 275 | require.Equal(t, StatusSystemStatus, rc) 276 | } 277 | 278 | func TestAuthPerClientTLSRequired(t *testing.T) { 279 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 280 | Debug: true, 281 | TLS: true, 282 | TLSRequirement: MandatoryEncryption, 283 | }) 284 | 285 | conf := goftp.Config{ 286 | User: authUser, 287 | Password: authPass, 288 | } 289 | 290 | client, err := goftp.DialConfig(conf, server.Addr()) 291 | require.NoError(t, err, "Couldn't connect") 292 | 293 | defer func() { panicOnError(client.Close()) }() 294 | 295 | _, err = client.OpenRawConn() 296 | require.Error(t, err, "Plain text login must fail, TLS is required") 297 | require.EqualError(t, err, "unexpected response: 421-TLS is required") 298 | 299 | conf.TLSConfig = &tls.Config{ 300 | InsecureSkipVerify: true, //nolint:gosec 301 | } 302 | conf.TLSMode = goftp.TLSExplicit 303 | 304 | client, err = goftp.DialConfig(conf, server.Addr()) 305 | require.NoError(t, err, "Couldn't connect") 306 | 307 | raw, err := client.OpenRawConn() 308 | require.NoError(t, err, "Couldn't open raw connection") 309 | 310 | defer func() { require.NoError(t, raw.Close()) }() 311 | 312 | rc, _, err := raw.SendCommand("STAT") 313 | require.NoError(t, err) 314 | require.Equal(t, StatusSystemStatus, rc) 315 | } 316 | 317 | func TestUserVerifierError(t *testing.T) { 318 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 319 | Debug: false, 320 | TLS: true, 321 | // setting an invalid TLS requirement will cause the test driver 322 | // to return an error in PreAuthUser 323 | TLSRequirement: -1, 324 | }) 325 | 326 | conf := goftp.Config{ 327 | User: authUser, 328 | Password: authPass, 329 | TLSConfig: &tls.Config{ 330 | InsecureSkipVerify: true, //nolint:gosec 331 | }, 332 | TLSMode: goftp.TLSExplicit, 333 | } 334 | 335 | c, err := goftp.DialConfig(conf, server.Addr()) 336 | require.NoError(t, err, "Couldn't connect") 337 | 338 | defer func() { panicOnError(c.Close()) }() 339 | 340 | _, err = c.OpenRawConn() 341 | require.Error(t, err, "Plain text login must fail, TLS is required") 342 | require.EqualError(t, err, "unexpected response: 530-User rejected: invalid TLS requirement") 343 | } 344 | -------------------------------------------------------------------------------- /handle_dirs.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "path" 10 | "strings" 11 | "time" 12 | 13 | "github.com/spf13/afero" 14 | ) 15 | 16 | // thrown if listing with a filePath isn't supported (MLSD, NLST) 17 | var errFileList = errors.New("listing a file isn't allowed") 18 | 19 | // the order matter, put parameters with more characters first 20 | var supportedlistArgs = []string{"-al", "-la", "-a", "-l"} //nolint:gochecknoglobals 21 | 22 | func (c *clientHandler) absPath(p string) string { 23 | if path.IsAbs(p) { 24 | return path.Clean(p) 25 | } 26 | 27 | return path.Join(c.Path(), p) 28 | } 29 | 30 | // getRelativePath returns the specified path as relative to the 31 | // current working directory. The specified path must be cleaned 32 | func (c *clientHandler) getRelativePath(inputPath string) string { 33 | var builder strings.Builder 34 | base := c.Path() 35 | 36 | for { 37 | if base == inputPath { 38 | return builder.String() 39 | } 40 | 41 | if !strings.HasSuffix(base, "/") { 42 | base += "/" 43 | } 44 | 45 | if strings.HasPrefix(inputPath, base) { 46 | builder.WriteString(strings.TrimPrefix(inputPath, base)) 47 | 48 | return builder.String() 49 | } 50 | 51 | if base == "/" || base == "./" { 52 | return inputPath 53 | } 54 | 55 | builder.WriteString("../") 56 | 57 | base = path.Dir(path.Clean(base)) 58 | } 59 | } 60 | 61 | func (c *clientHandler) handleCWD(param string) error { 62 | pathAbsolute := c.absPath(param) 63 | 64 | if stat, err := c.driver.Stat(pathAbsolute); err == nil { 65 | if stat.IsDir() { 66 | c.SetPath(pathAbsolute) 67 | c.writeMessage(StatusFileOK, "CD worked on "+pathAbsolute) 68 | } else { 69 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("Can't change directory to %s: Not a Directory", pathAbsolute)) 70 | } 71 | } else { 72 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("CD issue: %v", err)) 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (c *clientHandler) handleMKD(param string) error { 79 | pathAbsolute := c.absPath(param) 80 | if err := c.driver.Mkdir(pathAbsolute, 0o755); err == nil { 81 | // handleMKD confirms to "quote-doubling" 82 | // https://tools.ietf.org/html/rfc959 , page 63 83 | c.writeMessage(StatusPathCreated, fmt.Sprintf(`Created dir "%s"`, quoteDoubling(pathAbsolute))) 84 | } else { 85 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf(`Could not create "%s" : %v`, quoteDoubling(pathAbsolute), err)) 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func (c *clientHandler) handleMKDIR(params string) { 92 | if params == "" { 93 | c.writeMessage(StatusSyntaxErrorNotRecognised, "Missing path") 94 | 95 | return 96 | } 97 | 98 | p := c.absPath(params) 99 | 100 | if err := c.driver.MkdirAll(p, 0o755); err == nil { 101 | c.writeMessage(StatusFileOK, "Created dir "+p) 102 | } else { 103 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("Couldn't create dir %s: %v", p, err)) 104 | } 105 | } 106 | 107 | func (c *clientHandler) handleRMD(param string) error { 108 | var err error 109 | 110 | pathAbsolute := c.absPath(param) 111 | 112 | if rmd, ok := c.driver.(ClientDriverExtensionRemoveDir); ok { 113 | err = rmd.RemoveDir(pathAbsolute) 114 | } else { 115 | err = c.driver.Remove(pathAbsolute) 116 | } 117 | 118 | if err == nil { 119 | c.writeMessage(StatusFileOK, "Deleted dir "+pathAbsolute) 120 | } else { 121 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("Could not delete dir %s: %v", pathAbsolute, err)) 122 | } 123 | 124 | return nil 125 | } 126 | 127 | func (c *clientHandler) handleRMDIR(params string) { 128 | if params == "" { 129 | c.writeMessage(StatusSyntaxErrorNotRecognised, "Missing path") 130 | 131 | return 132 | } 133 | 134 | p := c.absPath(params) 135 | 136 | if err := c.driver.RemoveAll(p); err == nil { 137 | c.writeMessage(StatusFileOK, "Removed dir "+p) 138 | } else { 139 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("Couldn't remove dir %s: %v", p, err)) 140 | } 141 | } 142 | 143 | func (c *clientHandler) handleCDUP(_ string) error { 144 | parent, _ := path.Split(c.Path()) 145 | if parent != "/" && strings.HasSuffix(parent, "/") { 146 | parent = parent[0 : len(parent)-1] 147 | } 148 | 149 | if _, err := c.driver.Stat(parent); err == nil { 150 | c.SetPath(parent) 151 | c.writeMessage(StatusFileOK, "CDUP worked on "+parent) 152 | } else { 153 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("CDUP issue: %v", err)) 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (c *clientHandler) handlePWD(_ string) error { 160 | c.writeMessage(StatusPathCreated, fmt.Sprintf(`"%s" is the current directory`, quoteDoubling(c.Path()))) 161 | 162 | return nil 163 | } 164 | 165 | func (c *clientHandler) checkLISTArgs(args string) string { 166 | result := args 167 | param := strings.ToLower(args) 168 | 169 | for _, arg := range supportedlistArgs { 170 | if strings.HasPrefix(param, arg) { 171 | // a check for a non-existent directory error is more appropriate here 172 | // but we cannot assume that the driver implementation will return an 173 | // os.IsNotExist error. 174 | if _, err := c.driver.Stat(args); err != nil { 175 | params := strings.SplitN(args, " ", 2) 176 | if len(params) == 1 { 177 | result = "" 178 | } else { 179 | result = params[1] 180 | } 181 | } 182 | } 183 | } 184 | 185 | return result 186 | } 187 | 188 | func (c *clientHandler) handleLIST(param string) error { 189 | info := fmt.Sprintf("LIST %v", param) 190 | 191 | if files, _, err := c.getFileList(param, true); err == nil || errors.Is(err, io.EOF) { 192 | if tr, errTr := c.TransferOpen(info); errTr == nil { 193 | err = c.dirTransferLIST(tr, files) 194 | c.TransferClose(err) 195 | 196 | return nil 197 | } 198 | } else { 199 | if !c.isCommandAborted() { 200 | c.writeMessage(StatusFileActionNotTaken, fmt.Sprintf("Could not list: %v", err)) 201 | } 202 | } 203 | 204 | return nil 205 | } 206 | 207 | func (c *clientHandler) handleNLST(param string) error { 208 | info := fmt.Sprintf("NLST %v", param) 209 | 210 | if files, parentDir, err := c.getFileList(param, true); err == nil || errors.Is(err, io.EOF) { 211 | if tr, errTrOpen := c.TransferOpen(info); errTrOpen == nil { 212 | err = c.dirTransferNLST(tr, files, parentDir) 213 | c.TransferClose(err) 214 | 215 | return nil 216 | } 217 | } else { 218 | if !c.isCommandAborted() { 219 | c.writeMessage(StatusFileActionNotTaken, fmt.Sprintf("Could not list: %v", err)) 220 | } 221 | } 222 | 223 | return nil 224 | } 225 | 226 | func (c *clientHandler) dirTransferNLST(writer io.Writer, files []os.FileInfo, parentDir string) error { 227 | if len(files) == 0 { 228 | _, err := writer.Write([]byte("")) 229 | if err != nil { 230 | err = newNetworkError("couldn't send NLST data", err) 231 | } 232 | 233 | return err 234 | } 235 | 236 | for _, file := range files { 237 | // Based on RFC 959 NLST is intended to return information that can be used 238 | // by a program to further process the files automatically. 239 | // So we return paths relative to the current working directory 240 | if _, err := fmt.Fprintf(writer, "%s\r\n", path.Join(c.getRelativePath(parentDir), file.Name())); err != nil { 241 | return newNetworkError("couldn't send NLST data", err) 242 | } 243 | } 244 | 245 | return nil 246 | } 247 | 248 | func (c *clientHandler) handleMLSD(param string) error { 249 | if c.server.settings.DisableMLSD && !c.isCommandAborted() { 250 | c.writeMessage(StatusSyntaxErrorNotRecognised, "MLSD has been disabled") 251 | 252 | return nil 253 | } 254 | 255 | info := fmt.Sprintf("MLSD %v", param) 256 | 257 | if files, _, err := c.getFileList(param, false); err == nil || errors.Is(err, io.EOF) { 258 | if tr, errTr := c.TransferOpen(info); errTr == nil { 259 | err = c.dirTransferMLSD(tr, files) 260 | c.TransferClose(err) 261 | 262 | return nil 263 | } 264 | } else { 265 | if !c.isCommandAborted() { 266 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("Could not list: %v", err)) 267 | } 268 | } 269 | 270 | return nil 271 | } 272 | 273 | const ( 274 | dateFormatStatTime = "Jan _2 15:04" // LIST date formatting with hour and minute 275 | dateFormatStatYear = "Jan _2 2006" // LIST date formatting with year 276 | dateFormatStatOldSwitch = time.Hour * 24 * 30 * 6 // 6 months ago 277 | dateFormatMLSD = "20060102150405" // MLSD date formatting 278 | fakeUser = "ftp" 279 | fakeGroup = "ftp" 280 | ) 281 | 282 | func (c *clientHandler) fileStat(file os.FileInfo) string { 283 | modTime := file.ModTime() 284 | 285 | var dateFormat string 286 | 287 | if c.connectedAt.Sub(modTime) > dateFormatStatOldSwitch { 288 | dateFormat = dateFormatStatYear 289 | } else { 290 | dateFormat = dateFormatStatTime 291 | } 292 | 293 | return fmt.Sprintf( 294 | "%s 1 %s %s %12d %s %s", 295 | file.Mode(), 296 | fakeUser, 297 | fakeGroup, 298 | file.Size(), 299 | file.ModTime().Format(dateFormat), 300 | file.Name(), 301 | ) 302 | } 303 | 304 | // fclairamb (2018-02-13): #64: Removed extra empty line 305 | func (c *clientHandler) dirTransferLIST(writer io.Writer, files []os.FileInfo) error { 306 | if len(files) == 0 { 307 | _, err := writer.Write([]byte("")) 308 | if err != nil { 309 | err = newNetworkError("error writing LIST entry", err) 310 | } 311 | 312 | return err 313 | } 314 | 315 | for _, file := range files { 316 | if _, err := fmt.Fprintf(writer, "%s\r\n", c.fileStat(file)); err != nil { 317 | return fmt.Errorf("error writing LIST entry: %w", err) 318 | } 319 | } 320 | 321 | return nil 322 | } 323 | 324 | // fclairamb (2018-02-13): #64: Removed extra empty line 325 | func (c *clientHandler) dirTransferMLSD(writer io.Writer, files []os.FileInfo) error { 326 | if len(files) == 0 { 327 | _, err := writer.Write([]byte("")) 328 | if err != nil { 329 | err = newNetworkError("error writing MLSD entry", err) 330 | } 331 | 332 | return err 333 | } 334 | 335 | for _, file := range files { 336 | if err := c.writeMLSxEntry(writer, file); err != nil { 337 | return err 338 | } 339 | } 340 | 341 | return nil 342 | } 343 | 344 | func (c *clientHandler) writeMLSxEntry(writer io.Writer, file os.FileInfo) error { 345 | var listType string 346 | if file.IsDir() { 347 | listType = "dir" 348 | } else { 349 | listType = "file" 350 | } 351 | 352 | _, err := fmt.Fprintf( 353 | writer, 354 | "Type=%s;Size=%d;Modify=%s; %s\r\n", 355 | listType, 356 | file.Size(), 357 | file.ModTime().UTC().Format(dateFormatMLSD), 358 | file.Name(), 359 | ) 360 | if err != nil { 361 | err = fmt.Errorf("error writing MLSD entry: %w", err) 362 | } 363 | 364 | return err 365 | } 366 | 367 | func (c *clientHandler) getFileList(param string, filePathAllowed bool) ([]os.FileInfo, string, error) { 368 | if !c.server.settings.DisableLISTArgs { 369 | param = c.checkLISTArgs(param) 370 | } 371 | // directory or filePath 372 | listPath := c.absPath(param) 373 | c.SetListPath(listPath) 374 | 375 | // return list of single file if directoryPath points to file and filePathAllowed 376 | info, err := c.driver.Stat(listPath) 377 | if err != nil { 378 | return nil, "", newFileAccessError("couldn't stat", err) 379 | } 380 | 381 | if !info.IsDir() { 382 | if filePathAllowed { 383 | return []os.FileInfo{info}, path.Dir(c.getListPath()), nil 384 | } 385 | 386 | return nil, "", errFileList 387 | } 388 | 389 | var files []fs.FileInfo 390 | 391 | if fileList, ok := c.driver.(ClientDriverExtensionFileList); ok { 392 | files, err = fileList.ReadDir(listPath) 393 | 394 | return files, c.getListPath(), err 395 | } 396 | 397 | directory, errOpenFile := c.driver.Open(listPath) 398 | if errOpenFile != nil { 399 | return nil, "", newFileAccessError("couldn't open directory", errOpenFile) 400 | } 401 | 402 | defer c.closeDirectory(listPath, directory) 403 | 404 | files, err = directory.Readdir(-1) 405 | 406 | return files, c.getListPath(), err 407 | } 408 | 409 | func (c *clientHandler) closeDirectory(directoryPath string, directory afero.File) { 410 | if errClose := directory.Close(); errClose != nil { 411 | c.logger.Error("Couldn't close directory", "err", errClose, "directory", directoryPath) 412 | } 413 | } 414 | 415 | func quoteDoubling(s string) string { 416 | if !strings.Contains(s, "\"") { 417 | return s 418 | } 419 | 420 | return strings.ReplaceAll(s, "\"", `""`) 421 | } 422 | -------------------------------------------------------------------------------- /handle_dirs_test.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io" 7 | "net" 8 | "path" 9 | "testing" 10 | "time" 11 | 12 | "github.com/secsy/goftp" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | const DirKnown = "known" 17 | 18 | func TestGetRelativePaths(t *testing.T) { 19 | type relativePathTest struct { 20 | workingDir, path, result string 21 | } 22 | tests := []relativePathTest{ 23 | {"/", "/p", "p"}, 24 | {"/", "/", ""}, 25 | {"/p", "/p", ""}, 26 | {"/p", "/p1", "../p1"}, 27 | {"/p", "/p/p1", "p1"}, 28 | {"/p/p1", "/p/p2/p3", "../p2/p3"}, 29 | {"/", "p", "p"}, 30 | } 31 | 32 | handler := clientHandler{} 33 | 34 | for _, test := range tests { 35 | handler.SetPath(test.workingDir) 36 | result := handler.getRelativePath(test.path) 37 | require.Equal(t, test.result, result) 38 | } 39 | } 40 | 41 | func TestDirListing(t *testing.T) { 42 | // MLSD is disabled we relies on LIST of files listing 43 | server := NewTestServerWithTestDriver(t, &TestServerDriver{Debug: false, Settings: &Settings{DisableMLSD: true}}) 44 | conf := goftp.Config{ 45 | User: authUser, 46 | Password: authPass, 47 | } 48 | 49 | client, err := goftp.DialConfig(conf, server.Addr()) 50 | require.NoError(t, err, "Couldn't connect") 51 | 52 | defer func() { panicOnError(client.Close()) }() 53 | 54 | dirName, err := client.Mkdir(DirKnown) 55 | require.NoError(t, err, "Couldn't create dir") 56 | require.Equal(t, path.Join("/", DirKnown), dirName) 57 | 58 | contents, err := client.ReadDir("/") 59 | require.NoError(t, err) 60 | require.Len(t, contents, 1) 61 | require.Equal(t, DirKnown, contents[0].Name()) 62 | 63 | // LIST also works for filePath 64 | fileName := "testfile.ext" 65 | 66 | _, err = client.ReadDir(fileName) 67 | require.Error(t, err, "LIST for not existing filePath must fail") 68 | 69 | ftpUpload(t, client, createTemporaryFile(t, 10), fileName) 70 | 71 | fileContents, err := client.ReadDir(fileName) 72 | require.NoError(t, err) 73 | require.Len(t, fileContents, 1) 74 | require.Equal(t, fileName, fileContents[0].Name()) 75 | 76 | // the test driver will fail to open this dir 77 | dirName, err = client.Mkdir("fail-to-open-dir") 78 | require.NoError(t, err) 79 | _, err = client.ReadDir(dirName) 80 | require.Error(t, err) 81 | } 82 | 83 | func TestDirListingPathArg(t *testing.T) { 84 | // MLSD is disabled we relies on LIST of files listing 85 | server := NewTestServerWithTestDriver(t, &TestServerDriver{Debug: false, Settings: &Settings{DisableMLSD: true}}) 86 | conf := goftp.Config{ 87 | User: authUser, 88 | Password: authPass, 89 | } 90 | 91 | client, err := goftp.DialConfig(conf, server.Addr()) 92 | require.NoError(t, err, "Couldn't connect") 93 | 94 | defer func() { panicOnError(client.Close()) }() 95 | 96 | for _, dir := range []string{"/" + DirKnown, "/" + DirKnown + "/1"} { 97 | _, err = client.Mkdir(dir) 98 | require.NoError(t, err, "Couldn't create dir") 99 | } 100 | 101 | contents, err := client.ReadDir(DirKnown) 102 | require.NoError(t, err) 103 | require.Len(t, contents, 1) 104 | require.Equal(t, "1", contents[0].Name()) 105 | 106 | contents, err = client.ReadDir("") 107 | require.NoError(t, err) 108 | require.Len(t, contents, 1) 109 | require.Equal(t, DirKnown, contents[0].Name()) 110 | } 111 | 112 | func TestDirHandling(t *testing.T) { 113 | s := NewTestServer(t, false) 114 | 115 | client, err := goftp.DialConfig(goftp.Config{ 116 | User: authUser, 117 | Password: authPass, 118 | }, s.Addr()) 119 | require.NoError(t, err, "Couldn't connect") 120 | 121 | defer func() { panicOnError(client.Close()) }() 122 | 123 | raw, err := client.OpenRawConn() 124 | require.NoError(t, err, "Couldn't open raw connection") 125 | 126 | defer func() { require.NoError(t, raw.Close()) }() 127 | 128 | returnCode, _, err := raw.SendCommand("CWD /unknown") 129 | require.NoError(t, err) 130 | require.Equal(t, StatusActionNotTaken, returnCode) 131 | 132 | _, err = client.Mkdir("/" + DirKnown) 133 | require.NoError(t, err) 134 | 135 | contents, err := client.ReadDir("/") 136 | require.NoError(t, err) 137 | require.Len(t, contents, 1) 138 | 139 | returnCode, _, err = raw.SendCommand("CWD /" + DirKnown) 140 | require.NoError(t, err) 141 | require.Equal(t, StatusFileOK, returnCode) 142 | 143 | testSubdir := ` strange\\ sub" dìr` 144 | returnCode, _, err = raw.SendCommand(fmt.Sprintf("MKD %v", testSubdir)) 145 | require.NoError(t, err) 146 | require.Equal(t, StatusPathCreated, returnCode) 147 | 148 | returnCode, response, err := raw.SendCommand(fmt.Sprintf("CWD %v", testSubdir)) 149 | require.NoError(t, err) 150 | require.Equal(t, StatusFileOK, returnCode, response) 151 | 152 | returnCode, response, err = raw.SendCommand(fmt.Sprintf("PWD %v", testSubdir)) 153 | require.NoError(t, err) 154 | require.Equal(t, StatusPathCreated, returnCode, response) 155 | require.Equal(t, `"/known/ strange\\ sub"" dìr" is the current directory`, response) 156 | 157 | returnCode, response, err = raw.SendCommand("CDUP") 158 | require.NoError(t, err) 159 | require.Equal(t, StatusFileOK, returnCode, response) 160 | 161 | err = client.Rmdir(path.Join("/", DirKnown, testSubdir)) 162 | require.NoError(t, err) 163 | 164 | err = client.Rmdir(path.Join("/", DirKnown)) 165 | require.NoError(t, err) 166 | 167 | err = client.Rmdir(path.Join("/", DirKnown)) 168 | require.Error(t, err, "We shouldn't have been able to ftpDelete known again") 169 | } 170 | 171 | func TestCWDToRegularFile(t *testing.T) { 172 | server := NewTestServer(t, false) 173 | conf := goftp.Config{ 174 | User: authUser, 175 | Password: authPass, 176 | } 177 | 178 | client, err := goftp.DialConfig(conf, server.Addr()) 179 | require.NoError(t, err, "Couldn't connect") 180 | 181 | defer func() { panicOnError(client.Close()) }() 182 | 183 | // Getwd will send a PWD command 184 | p, err := client.Getwd() 185 | require.NoError(t, err) 186 | require.Equal(t, "/", p, "Bad path") 187 | 188 | raw, err := client.OpenRawConn() 189 | require.NoError(t, err, "Couldn't open raw connection") 190 | 191 | defer func() { require.NoError(t, raw.Close()) }() 192 | 193 | // Creating a tiny file 194 | ftpUpload(t, client, createTemporaryFile(t, 10), "file.txt") 195 | 196 | rc, msg, err := raw.SendCommand("CWD /file.txt") 197 | require.NoError(t, err) 198 | require.Equal(t, `Can't change directory to /file.txt: Not a Directory`, msg) 199 | require.Equal(t, StatusActionNotTaken, rc, "We shouldn't have been able to CWD to a regular file") 200 | } 201 | 202 | func TestMkdirRmDir(t *testing.T) { 203 | server := NewTestServer(t, false) 204 | req := require.New(t) 205 | conf := goftp.Config{ 206 | User: authUser, 207 | Password: authPass, 208 | } 209 | 210 | client, err := goftp.DialConfig(conf, server.Addr()) 211 | req.NoError(err, "Couldn't connect") 212 | 213 | defer func() { panicOnError(client.Close()) }() 214 | 215 | raw, err := client.OpenRawConn() 216 | req.NoError(err, "Couldn't open raw connection") 217 | 218 | defer func() { require.NoError(t, raw.Close()) }() 219 | 220 | t.Run("standard", func(t *testing.T) { 221 | returnCode, _, err := raw.SendCommand("SITE MKDIR /dir1/dir2/dir3") 222 | require.NoError(t, err) 223 | require.Equal(t, StatusFileOK, returnCode) 224 | 225 | for _, d := range []string{"/dir1", "/dir1/dir2", "/dir1/dir2/dir3"} { 226 | stat, errStat := client.Stat(d) 227 | require.NoError(t, errStat) 228 | require.True(t, stat.IsDir()) 229 | } 230 | 231 | returnCode, _, err = raw.SendCommand("SITE RMDIR /dir1") 232 | require.NoError(t, err) 233 | require.Equal(t, StatusFileOK, returnCode) 234 | 235 | for _, d := range []string{"/dir1", "/dir1/dir2", "/dir1/dir2/dir3"} { 236 | stat, errStat := client.Stat(d) 237 | require.Error(t, errStat) 238 | require.Nil(t, stat) 239 | } 240 | 241 | _, err = client.Mkdir("/missing/path") 242 | require.Error(t, err) 243 | }) 244 | 245 | t.Run("syntax error", func(t *testing.T) { 246 | returnCode, _, err := raw.SendCommand("SITE MKDIR") 247 | require.NoError(t, err) 248 | require.Equal(t, StatusSyntaxErrorNotRecognised, returnCode) 249 | 250 | returnCode, _, err = raw.SendCommand("SITE RMDIR") 251 | require.NoError(t, err) 252 | require.Equal(t, StatusSyntaxErrorNotRecognised, returnCode) 253 | }) 254 | 255 | t.Run("spaces", func(t *testing.T) { 256 | returnCode, _, err := raw.SendCommand("SITE MKDIR /dir1 /dir2") 257 | require.NoError(t, err) 258 | require.Equal(t, StatusFileOK, returnCode) 259 | 260 | { 261 | dir := "/dir1 /dir2" 262 | stat, errStat := client.Stat(dir) 263 | require.NoError(t, errStat) 264 | require.True(t, stat.IsDir()) 265 | } 266 | 267 | returnCode, _, err = raw.SendCommand("SITE RMDIR /dir1 /dir2") 268 | require.NoError(t, err) 269 | require.Equal(t, StatusFileOK, returnCode) 270 | }) 271 | } 272 | 273 | // TestDirListingWithSpace uses the MLSD for files listing 274 | func TestDirListingWithSpace(t *testing.T) { 275 | server := NewTestServer(t, false) 276 | conf := goftp.Config{ 277 | User: authUser, 278 | Password: authPass, 279 | } 280 | 281 | client, err := goftp.DialConfig(conf, server.Addr()) 282 | require.NoError(t, err, "Couldn't connect") 283 | 284 | defer func() { panicOnError(client.Close()) }() 285 | 286 | dirName := " with spaces " 287 | 288 | _, err = client.Mkdir(dirName) 289 | require.NoError(t, err, "Couldn't create dir") 290 | 291 | contents, err := client.ReadDir("/") 292 | require.NoError(t, err) 293 | require.Len(t, contents, 1) 294 | require.Equal(t, dirName, contents[0].Name()) 295 | 296 | raw, err := client.OpenRawConn() 297 | require.NoError(t, err, "Couldn't open raw connection") 298 | 299 | defer func() { require.NoError(t, raw.Close()) }() 300 | 301 | returnCode, response, err := raw.SendCommand("CWD /" + dirName) 302 | require.NoError(t, err) 303 | require.Equal(t, StatusFileOK, returnCode) 304 | require.Equal(t, "CD worked on /"+dirName, response) 305 | 306 | dcGetter, err := raw.PrepareDataConn() 307 | require.NoError(t, err) 308 | 309 | returnCode, response, err = raw.SendCommand("NLST /") 310 | require.NoError(t, err) 311 | require.Equal(t, StatusFileStatusOK, returnCode, response) 312 | 313 | dc, err := dcGetter() 314 | require.NoError(t, err) 315 | resp, err := io.ReadAll(dc) 316 | require.NoError(t, err) 317 | require.Equal(t, "../"+dirName+"\r\n", string(resp)) 318 | 319 | returnCode, _, err = raw.ReadResponse() 320 | require.NoError(t, err) 321 | require.Equal(t, StatusClosingDataConn, returnCode) 322 | 323 | _, err = raw.PrepareDataConn() 324 | require.NoError(t, err) 325 | 326 | returnCode, response, err = raw.SendCommand("NLST /missingpath") 327 | require.NoError(t, err) 328 | require.Equal(t, StatusFileActionNotTaken, returnCode, response) 329 | } 330 | 331 | func TestCleanPath(t *testing.T) { 332 | server := NewTestServer(t, false) 333 | conf := goftp.Config{ 334 | User: authUser, 335 | Password: authPass, 336 | } 337 | 338 | client, err := goftp.DialConfig(conf, server.Addr()) 339 | require.NoError(t, err, "Couldn't connect") 340 | 341 | defer func() { panicOnError(client.Close()) }() 342 | 343 | raw, err := client.OpenRawConn() 344 | require.NoError(t, err, "Couldn't open raw connection") 345 | 346 | defer func() { require.NoError(t, raw.Close()) }() 347 | 348 | // various path purity tests 349 | 350 | for _, dir := range []string{ 351 | "..", 352 | "../..", 353 | "/../..", 354 | "////", 355 | "/./", 356 | "/././.", 357 | } { 358 | rc, response, err := raw.SendCommand("CWD " + dir) 359 | require.NoError(t, err) 360 | require.Equal(t, StatusFileOK, rc) 361 | require.Equal(t, "CD worked on /", response) 362 | 363 | p, err := client.Getwd() 364 | require.NoError(t, err) 365 | require.Equal(t, "/", p) 366 | } 367 | } 368 | 369 | func TestTLSTransfer(t *testing.T) { 370 | req := require.New(t) 371 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 372 | Debug: false, 373 | TLS: true, 374 | }) 375 | server.settings.TLSRequired = MandatoryEncryption 376 | 377 | conf := goftp.Config{ 378 | User: authUser, 379 | Password: authPass, 380 | TLSConfig: &tls.Config{ 381 | //nolint:gosec 382 | InsecureSkipVerify: true, 383 | }, 384 | TLSMode: goftp.TLSExplicit, 385 | } 386 | 387 | client, err := goftp.DialConfig(conf, server.Addr()) 388 | req.NoError(err, "Couldn't connect") 389 | 390 | defer func() { panicOnError(client.Close()) }() 391 | 392 | contents, err := client.ReadDir("/") 393 | req.NoError(err) 394 | req.Empty(contents) 395 | 396 | raw, err := client.OpenRawConn() 397 | req.NoError(err, "Couldn't open raw connection") 398 | 399 | defer func() { require.NoError(t, raw.Close()) }() 400 | 401 | returnCode, response, err := raw.SendCommand("PROT C") 402 | req.NoError(err) 403 | req.Equal(StatusOK, returnCode) 404 | req.Equal("OK", response) 405 | 406 | returnCode, _, err = raw.SendCommand("PASV") 407 | req.NoError(err) 408 | req.Equal(StatusEnteringPASV, returnCode) 409 | 410 | returnCode, response, err = raw.SendCommand("MLSD /") 411 | req.NoError(err) 412 | req.Equal(StatusServiceNotAvailable, returnCode) 413 | req.Equal("unable to open transfer: TLS is required", response) 414 | } 415 | 416 | func TestPerClientTLSTransfer(t *testing.T) { 417 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 418 | Debug: true, 419 | TLS: true, 420 | TLSRequirement: MandatoryEncryption, 421 | }) 422 | 423 | conf := goftp.Config{ 424 | User: authUser, 425 | Password: authPass, 426 | TLSConfig: &tls.Config{ 427 | InsecureSkipVerify: true, //nolint:gosec 428 | }, 429 | TLSMode: goftp.TLSExplicit, 430 | } 431 | 432 | client, err := goftp.DialConfig(conf, server.Addr()) 433 | require.NoError(t, err, "Couldn't connect") 434 | 435 | defer func() { panicOnError(client.Close()) }() 436 | 437 | _, err = client.ReadDir("/") 438 | require.NoError(t, err) 439 | 440 | // now switch to unencrypted data connection 441 | raw, err := client.OpenRawConn() 442 | require.NoError(t, err, "Couldn't open raw connection") 443 | 444 | defer func() { require.NoError(t, raw.Close()) }() 445 | 446 | returnCode, resp, err := raw.SendCommand("PROT C") 447 | require.NoError(t, err) 448 | require.Equal(t, StatusOK, returnCode) 449 | require.Equal(t, "OK", resp) 450 | 451 | returnCode, _, err = raw.SendCommand("PASV") 452 | require.NoError(t, err) 453 | require.Equal(t, StatusEnteringPASV, returnCode) 454 | 455 | returnCode, response, err := raw.SendCommand("MLSD /") 456 | require.NoError(t, err) 457 | require.Equal(t, StatusServiceNotAvailable, returnCode) 458 | require.Equal(t, "unable to open transfer: TLS is required", response) 459 | } 460 | 461 | func TestDirListingBeforeLogin(t *testing.T) { 462 | s := NewTestServer(t, false) 463 | conn, err := net.DialTimeout("tcp", s.Addr(), 5*time.Second) 464 | require.NoError(t, err) 465 | 466 | defer func() { 467 | err = conn.Close() 468 | require.NoError(t, err) 469 | }() 470 | 471 | buf := make([]byte, 1024) 472 | readBytes, err := conn.Read(buf) 473 | require.NoError(t, err) 474 | 475 | response := string(buf[:readBytes]) 476 | require.Equal(t, "220 TEST Server\r\n", response) 477 | 478 | _, err = conn.Write([]byte("LIST\r\n")) 479 | require.NoError(t, err) 480 | 481 | readBytes, err = conn.Read(buf) 482 | require.NoError(t, err) 483 | 484 | response = string(buf[:readBytes]) 485 | require.Equal(t, "530 Please login with USER and PASS\r\n", response) 486 | } 487 | 488 | func TestListArgs(t *testing.T) { 489 | t.Parallel() 490 | 491 | t.Run("with-mlsd", func(t *testing.T) { 492 | t.Parallel() 493 | testListDirArgs( 494 | t, 495 | NewTestServer(t, false), 496 | ) 497 | }) 498 | 499 | t.Run("without-mlsd", func(t *testing.T) { 500 | t.Parallel() 501 | testListDirArgs( 502 | t, 503 | NewTestServerWithTestDriver(t, &TestServerDriver{Debug: false, Settings: &Settings{DisableMLSD: true}}), 504 | ) 505 | }) 506 | } 507 | 508 | func testListDirArgs(t *testing.T, server *FtpServer) { 509 | t.Helper() 510 | req := require.New(t) 511 | 512 | conf := goftp.Config{ 513 | User: authUser, 514 | Password: authPass, 515 | } 516 | testDir := "testdir" 517 | 518 | client, err := goftp.DialConfig(conf, server.Addr()) 519 | req.NoError(err, "Couldn't connect") 520 | 521 | defer func() { panicOnError(client.Close()) }() 522 | 523 | for _, arg := range supportedlistArgs { 524 | server.settings.DisableLISTArgs = true 525 | 526 | _, err = client.ReadDir(arg) 527 | require.Error(t, err, fmt.Sprintf("list args are disabled \"list %v\" must fail", arg)) 528 | 529 | server.settings.DisableLISTArgs = false 530 | 531 | contents, err := client.ReadDir(arg) 532 | req.NoError(err) 533 | req.Empty(contents) 534 | 535 | _, err = client.Mkdir(arg) 536 | req.NoError(err) 537 | 538 | _, err = client.Mkdir(fmt.Sprintf("%v/%v", arg, testDir)) 539 | req.NoError(err) 540 | 541 | contents, err = client.ReadDir(arg) 542 | req.NoError(err) 543 | req.Len(contents, 1) 544 | req.Equal(contents[0].Name(), testDir) 545 | 546 | contents, err = client.ReadDir(fmt.Sprintf("%v %v", arg, arg)) 547 | req.NoError(err) 548 | req.Len(contents, 1) 549 | req.Equal(contents[0].Name(), testDir) 550 | 551 | // cleanup 552 | err = client.Rmdir(fmt.Sprintf("%v/%v", arg, testDir)) 553 | req.NoError(err) 554 | 555 | err = client.Rmdir(arg) 556 | req.NoError(err) 557 | } 558 | } 559 | 560 | func TestMLSDTimezone(t *testing.T) { 561 | server := NewTestServer(t, false) 562 | conf := goftp.Config{ 563 | User: authUser, 564 | Password: authPass, 565 | } 566 | 567 | client, err := goftp.DialConfig(conf, server.Addr()) 568 | require.NoError(t, err, "Couldn't connect") 569 | 570 | defer func() { panicOnError(client.Close()) }() 571 | 572 | ftpUpload(t, client, createTemporaryFile(t, 10), "file") 573 | contents, err := client.ReadDir("/") 574 | require.NoError(t, err) 575 | require.Len(t, contents, 1) 576 | require.Equal(t, "file", contents[0].Name()) 577 | require.InDelta(t, time.Now().Unix(), contents[0].ModTime().Unix(), 5) 578 | } 579 | 580 | func TestMLSDAndNLSTFilePathError(t *testing.T) { 581 | server := NewTestServer(t, false) 582 | conf := goftp.Config{ 583 | User: authUser, 584 | Password: authPass, 585 | } 586 | 587 | client, err := goftp.DialConfig(conf, server.Addr()) 588 | require.NoError(t, err, "Couldn't connect") 589 | 590 | defer func() { panicOnError(client.Close()) }() 591 | 592 | // MLSD shouldn't work for filePaths 593 | fileName := "testfile.ext" 594 | 595 | _, err = client.ReadDir(fileName) 596 | require.Error(t, err, "MLSD for not existing filePath must fail") 597 | 598 | ftpUpload(t, client, createTemporaryFile(t, 10), fileName) 599 | 600 | _, err = client.ReadDir(fileName) 601 | require.Error(t, err, "MLSD is enabled, MLSD for filePath must fail") 602 | 603 | // NLST should work for filePath 604 | raw, err := client.OpenRawConn() 605 | require.NoError(t, err, "Couldn't open raw connection") 606 | 607 | defer func() { require.NoError(t, raw.Close()) }() 608 | 609 | dcGetter, err := raw.PrepareDataConn() 610 | require.NoError(t, err) 611 | 612 | rc, response, err := raw.SendCommand("NLST /../" + fileName) 613 | require.NoError(t, err) 614 | require.Equal(t, StatusFileStatusOK, rc, response) 615 | 616 | dc, err := dcGetter() 617 | require.NoError(t, err) 618 | resp, err := io.ReadAll(dc) 619 | require.NoError(t, err) 620 | require.Equal(t, fileName+"\r\n", string(resp)) 621 | } 622 | -------------------------------------------------------------------------------- /handle_misc.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var errUnknowHash = errors.New("unknown hash algorithm") 14 | 15 | func (c *clientHandler) handleAUTH(_ string) error { 16 | if tlsConfig, err := c.server.driver.GetTLSConfig(); err == nil { 17 | c.writeMessage(StatusAuthAccepted, "AUTH command ok. Expecting TLS Negotiation.") 18 | c.conn = tls.Server(c.conn, tlsConfig) 19 | c.reader = bufio.NewReaderSize(c.conn, maxCommandSize) 20 | c.writer = bufio.NewWriter(c.conn) 21 | c.setTLSForControl(true) 22 | } else { 23 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("Cannot get a TLS config: %v", err)) 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func (c *clientHandler) handlePROT(param string) error { 30 | // P for Private, C for Clear 31 | c.setTLSForTransfer(param == "P") 32 | c.writeMessage(StatusOK, "OK") 33 | 34 | return nil 35 | } 36 | 37 | func (c *clientHandler) handlePBSZ(_ string) error { 38 | c.writeMessage(StatusOK, "Whatever") 39 | 40 | return nil 41 | } 42 | 43 | func (c *clientHandler) handleSYST(_ string) error { 44 | if c.server.settings.DisableSYST { 45 | c.writeMessage(StatusCommandNotImplemented, "SYST is disabled") 46 | 47 | return nil 48 | } 49 | 50 | c.writeMessage(StatusSystemType, "UNIX Type: L8") 51 | 52 | return nil 53 | } 54 | 55 | func (c *clientHandler) handleSTAT(param string) error { 56 | if param == "" { // Without a file, it's the server stat 57 | return c.handleSTATServer() 58 | } 59 | 60 | // With a file/dir it's the file or the dir's files stat 61 | return c.handleSTATFile(param) 62 | } 63 | 64 | func (c *clientHandler) handleSITE(param string) error { 65 | if c.server.settings.DisableSite { 66 | c.writeMessage(StatusSyntaxErrorNotRecognised, "SITE support is disabled") 67 | 68 | return nil 69 | } 70 | 71 | spl := strings.SplitN(param, " ", 2) 72 | cmd := strings.ToUpper(spl[0]) 73 | var params string 74 | 75 | if len(spl) > 1 { 76 | params = spl[1] 77 | } else { 78 | params = "" 79 | } 80 | 81 | switch cmd { 82 | case "CHMOD": 83 | c.handleCHMOD(params) 84 | case "CHOWN": 85 | c.handleCHOWN(params) 86 | case "SYMLINK": 87 | c.handleSYMLINK(params) 88 | case "MKDIR": 89 | c.handleMKDIR(params) 90 | case "RMDIR": 91 | c.handleRMDIR(params) 92 | default: 93 | c.writeMessage(StatusSyntaxErrorNotRecognised, "Unknown SITE subcommand: "+cmd) 94 | } 95 | 96 | return nil 97 | } 98 | 99 | func (c *clientHandler) handleSTATServer() error { 100 | // we need to hold the transfer lock here: 101 | // server STAT is a special action command so we need to ensure 102 | // to write the whole STAT response before sending a transfer 103 | // open/close message 104 | c.transferMu.Lock() 105 | defer c.transferMu.Unlock() 106 | 107 | if c.server.settings.DisableSTAT { 108 | c.writeMessage(StatusCommandNotImplemented, "STAT is disabled") 109 | 110 | return nil 111 | } 112 | 113 | defer c.multilineAnswer(StatusSystemStatus, "Server status")() 114 | 115 | duration := time.Now().UTC().Sub(c.connectedAt) 116 | duration -= duration % time.Second 117 | c.writeLine(fmt.Sprintf( 118 | "Connected to %s from %s for %s", 119 | c.server.settings.ListenAddr, 120 | c.conn.RemoteAddr(), 121 | duration, 122 | )) 123 | 124 | if c.user != "" { 125 | c.writeLine("Logged in as " + c.user) 126 | } else { 127 | c.writeLine("Not logged in yet") 128 | } 129 | 130 | if info := c.GetTranferInfo(); info != "" { 131 | c.writeLine("Transfer connection open") 132 | c.writeLine(info) 133 | } 134 | 135 | c.writeLine(c.server.settings.Banner) 136 | 137 | return nil 138 | } 139 | 140 | func (c *clientHandler) handleOptsUtf8() error { 141 | c.writeMessage(StatusOK, "I'm in UTF8 only anyway") 142 | 143 | return nil 144 | } 145 | 146 | func (c *clientHandler) handleOptsHash(args []string) error { 147 | hashMapping := getHashMapping() 148 | 149 | if len(args) > 0 { 150 | // try to change the current hash algorithm to the requested one 151 | if value, ok := hashMapping[args[0]]; ok { 152 | c.selectedHashAlgo = value 153 | c.writeMessage(StatusOK, args[0]) 154 | } else { 155 | c.writeMessage(StatusSyntaxErrorParameters, "Unknown algorithm, current selection not changed") 156 | } 157 | 158 | return nil 159 | } 160 | // return the current hash algorithm 161 | var currentHash string 162 | 163 | for k, v := range hashMapping { 164 | if v == c.selectedHashAlgo { 165 | currentHash = k 166 | } 167 | } 168 | 169 | c.writeMessage(StatusOK, currentHash) 170 | 171 | return nil 172 | } 173 | 174 | func (c *clientHandler) handleOPTS(param string) error { 175 | args := strings.SplitN(param, " ", 2) 176 | 177 | switch strings.ToUpper(args[0]) { 178 | case "UTF8": 179 | return c.handleOptsUtf8() 180 | case "HASH": 181 | if c.server.settings.EnableHASH { 182 | return c.handleOptsHash(args[1:]) 183 | } 184 | } 185 | 186 | c.writeMessage(StatusSyntaxErrorNotRecognised, "Don't know this option") 187 | 188 | return nil 189 | } 190 | 191 | func (c *clientHandler) handleNOOP(_ string) error { 192 | c.writeMessage(StatusOK, "OK") 193 | 194 | return nil 195 | } 196 | 197 | func (c *clientHandler) handleCLNT(param string) error { 198 | c.setClientVersion(param) 199 | c.writeMessage(StatusOK, "Good to know") 200 | 201 | return nil 202 | } 203 | 204 | func (c *clientHandler) handleFEAT(_ string) error { 205 | c.writeLine(fmt.Sprintf("%d- These are my features", StatusSystemStatus)) 206 | defer c.writeMessage(StatusSystemStatus, "end") 207 | 208 | features := []string{ 209 | "CLNT", 210 | "UTF8", 211 | "SIZE", 212 | "MDTM", 213 | "REST STREAM", 214 | "EPRT", 215 | "EPSV", 216 | } 217 | 218 | if !c.server.settings.DisableMLSD { 219 | features = append(features, "MLSD") 220 | } 221 | 222 | if !c.server.settings.DisableMLST { 223 | features = append(features, "MLST") 224 | } 225 | 226 | if !c.server.settings.DisableMFMT { 227 | features = append(features, "MFMT") 228 | } 229 | 230 | // This code made me think about adding this: https://github.com/stianstr/ftpserver/commit/387f2ba 231 | if tlsConfig, err := c.server.driver.GetTLSConfig(); tlsConfig != nil && err == nil { 232 | features = append(features, "AUTH TLS", "PBSZ", "PROT") 233 | } 234 | 235 | if c.server.settings.EnableHASH { 236 | var hashLine strings.Builder 237 | 238 | nonStandardHashImpl := []string{"XCRC", "MD5", "XMD5", "XSHA", "XSHA1", "XSHA256", "XSHA512"} 239 | hashMapping := getHashMapping() 240 | 241 | for k, v := range hashMapping { 242 | hashLine.WriteString(k) 243 | 244 | if v == c.selectedHashAlgo { 245 | hashLine.WriteString("*") 246 | } 247 | 248 | hashLine.WriteString(";") 249 | } 250 | 251 | features = append(features, hashLine.String()) 252 | features = append(features, nonStandardHashImpl...) 253 | } 254 | 255 | if c.server.settings.EnableCOMB { 256 | features = append(features, "COMB") 257 | } 258 | 259 | if _, ok := c.driver.(ClientDriverExtensionAvailableSpace); ok { 260 | features = append(features, "AVBL") 261 | } 262 | 263 | for _, f := range features { 264 | c.writeLine(" " + f) 265 | } 266 | 267 | return nil 268 | } 269 | 270 | func (c *clientHandler) handleTYPE(param string) error { 271 | param = strings.ReplaceAll(strings.ToUpper(param), " ", "") 272 | switch param { 273 | case "I", "L8": 274 | c.currentTransferType = TransferTypeBinary 275 | c.writeMessage(StatusOK, "Type set to binary") 276 | case "A", "AN", "L7": 277 | c.currentTransferType = TransferTypeASCII 278 | c.writeMessage(StatusOK, "Type set to ASCII") 279 | default: 280 | c.writeMessage(StatusNotImplementedParam, "Unsupported transfer type") 281 | } 282 | 283 | return nil 284 | } 285 | 286 | func (c *clientHandler) handleMODE(param string) error { 287 | if param == "S" { 288 | c.writeMessage(StatusOK, "Using stream mode") 289 | } else { 290 | c.writeMessage(StatusNotImplementedParam, "Unsupported mode") 291 | } 292 | 293 | return nil 294 | } 295 | 296 | func (c *clientHandler) handleQUIT(_ string) error { 297 | c.transferWg.Wait() 298 | 299 | var msg string 300 | 301 | if quitter, ok := c.server.driver.(MainDriverExtensionQuitMessage); ok { 302 | msg = quitter.QuitMessage() 303 | } else { 304 | msg = "Goodbye" 305 | } 306 | 307 | c.writeMessage(StatusClosingControlConn, msg) 308 | c.disconnect() 309 | c.reader = nil 310 | 311 | return nil 312 | } 313 | 314 | func (c *clientHandler) handleABOR(param string) error { 315 | c.transferMu.Lock() 316 | defer c.transferMu.Unlock() 317 | 318 | if c.transfer != nil { 319 | isOpened := c.isTransferOpen 320 | 321 | c.isTransferAborted = true 322 | 323 | if err := c.closeTransfer(); err != nil { 324 | c.logger.Warn( 325 | "Problem aborting transfer for command", param, 326 | "err", err, 327 | ) 328 | } 329 | 330 | if c.debug { 331 | c.logger.Debug( 332 | "Transfer aborted", 333 | "command", param) 334 | } 335 | 336 | if isOpened { 337 | c.writeMessage(StatusTransferAborted, "Connection closed; transfer aborted") 338 | } 339 | } 340 | 341 | c.writeMessage(StatusClosingDataConn, "ABOR successful; closing transfer connection") 342 | 343 | return nil 344 | } 345 | 346 | func (c *clientHandler) handleAVBL(param string) error { 347 | if avbl, ok := c.driver.(ClientDriverExtensionAvailableSpace); ok { 348 | path := c.absPath(param) 349 | 350 | info, err := c.driver.Stat(path) 351 | if err != nil { 352 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("Couldn't access %s: %v", path, err)) 353 | 354 | return nil 355 | } 356 | 357 | if !info.IsDir() { 358 | c.writeMessage(StatusActionNotTaken, path+": is not a directory") 359 | 360 | return nil 361 | } 362 | 363 | available, err := avbl.GetAvailableSpace(path) 364 | if err != nil { 365 | c.writeMessage(StatusActionNotTaken, fmt.Sprintf("Couldn't get space for path %s: %v", path, err)) 366 | 367 | return nil 368 | } 369 | 370 | c.writeMessage(StatusFileStatus, strconv.FormatInt(available, 10)) 371 | } else { 372 | c.writeMessage(StatusNotImplemented, "This extension hasn't been implemented !") 373 | } 374 | 375 | return nil 376 | } 377 | 378 | func (c *clientHandler) handleNotImplemented(_ string) error { 379 | c.writeMessage(StatusCommandNotImplemented, "This command hasn't been implemented !") 380 | 381 | return nil 382 | } 383 | -------------------------------------------------------------------------------- /handle_misc_test.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/secsy/goftp" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestSiteCommand(t *testing.T) { 17 | server := NewTestServer(t, false) 18 | conf := goftp.Config{ 19 | User: authUser, 20 | Password: authPass, 21 | } 22 | 23 | client, err := goftp.DialConfig(conf, server.Addr()) 24 | require.NoError(t, err, "Couldn't connect") 25 | 26 | defer func() { panicOnError(client.Close()) }() 27 | 28 | raw, err := client.OpenRawConn() 29 | require.NoError(t, err, "Couldn't open raw connection") 30 | 31 | defer func() { require.NoError(t, raw.Close()) }() 32 | 33 | rc, response, err := raw.SendCommand("SITE help") 34 | require.NoError(t, err) 35 | require.Equal(t, StatusSyntaxErrorNotRecognised, rc, "Are we supporting it now ?") 36 | require.Equal(t, "Unknown SITE subcommand: HELP", response, "Are we supporting it now ?") 37 | } 38 | 39 | // florent(2018-01-14): #58: IDLE timeout: Testing timeout 40 | // drakkan(2020-12-12): idle time is broken if you set timeout to 1 minute 41 | // and a transfer requires more than 1 minutes any command issued at the transfer end 42 | // will timeout. I handle idle timeout myself in SFTPGo but you could be 43 | // interested to fix this bug 44 | func TestIdleTimeout(t *testing.T) { 45 | server := NewTestServerWithTestDriver(t, &TestServerDriver{Debug: false, Settings: &Settings{IdleTimeout: 2}}) 46 | conf := goftp.Config{ 47 | User: authUser, 48 | Password: authPass, 49 | } 50 | 51 | c, err := goftp.DialConfig(conf, server.Addr()) 52 | require.NoError(t, err, "Couldn't connect") 53 | 54 | defer func() { panicOnError(c.Close()) }() 55 | 56 | raw, err := c.OpenRawConn() 57 | require.NoError(t, err, "Couldn't open raw connection") 58 | 59 | defer func() { require.NoError(t, raw.Close()) }() 60 | 61 | time.Sleep(time.Second * 1) // < 2s : OK 62 | 63 | returnCode, _, err := raw.SendCommand("NOOP") 64 | require.NoError(t, err) 65 | require.Equal(t, StatusOK, returnCode) 66 | 67 | time.Sleep(time.Second * 3) // > 2s : Timeout 68 | 69 | returnCode, _, err = raw.SendCommand("NOOP") 70 | require.NoError(t, err) 71 | require.Equal(t, StatusServiceNotAvailable, returnCode) 72 | } 73 | 74 | func TestStat(t *testing.T) { 75 | server := NewTestServer(t, false) 76 | conf := goftp.Config{ 77 | User: authUser, 78 | Password: authPass, 79 | } 80 | 81 | client, err := goftp.DialConfig(conf, server.Addr()) 82 | require.NoError(t, err, "Couldn't connect") 83 | 84 | defer func() { panicOnError(client.Close()) }() 85 | 86 | raw, err := client.OpenRawConn() 87 | require.NoError(t, err, "Couldn't open raw connection") 88 | 89 | returnCode, str, err := raw.SendCommand("STAT") 90 | require.NoError(t, err) 91 | require.Equal(t, StatusSystemStatus, returnCode) 92 | 93 | count := strings.Count(str, "\n") 94 | require.GreaterOrEqual(t, count, 4) 95 | require.NotEqual(t, ' ', str[0]) 96 | 97 | server.settings.DisableSTAT = true 98 | 99 | returnCode, str, err = raw.SendCommand("STAT") 100 | require.NoError(t, err) 101 | require.Equal(t, StatusCommandNotImplemented, returnCode, str) 102 | } 103 | 104 | func TestCLNT(t *testing.T) { 105 | server := NewTestServer(t, false) 106 | conf := goftp.Config{ 107 | User: authUser, 108 | Password: authPass, 109 | } 110 | 111 | c, err := goftp.DialConfig(conf, server.Addr()) 112 | require.NoError(t, err, "Couldn't connect") 113 | 114 | defer func() { panicOnError(c.Close()) }() 115 | 116 | raw, err := c.OpenRawConn() 117 | require.NoError(t, err, "Couldn't open raw connection") 118 | 119 | defer func() { require.NoError(t, raw.Close()) }() 120 | 121 | rc, _, err := raw.SendCommand("CLNT NcFTP 3.2.6 macosx10.15") 122 | require.NoError(t, err) 123 | require.Equal(t, StatusOK, rc) 124 | } 125 | 126 | func TestOPTSUTF8(t *testing.T) { 127 | server := NewTestServer(t, false) 128 | conf := goftp.Config{ 129 | User: authUser, 130 | Password: authPass, 131 | } 132 | 133 | client, err := goftp.DialConfig(conf, server.Addr()) 134 | require.NoError(t, err, "Couldn't connect") 135 | 136 | defer func() { panicOnError(client.Close()) }() 137 | 138 | raw, err := client.OpenRawConn() 139 | require.NoError(t, err, "Couldn't open raw connection") 140 | 141 | defer func() { require.NoError(t, raw.Close()) }() 142 | 143 | for _, cmd := range []string{"OPTS UTF8", "OPTS UTF8 ON"} { 144 | rc, message, err := raw.SendCommand(cmd) 145 | require.NoError(t, err) 146 | require.Equal(t, StatusOK, rc) 147 | require.Equal(t, "I'm in UTF8 only anyway", message) 148 | } 149 | } 150 | 151 | func TestOPTSHASH(t *testing.T) { 152 | server := NewTestServerWithTestDriver( 153 | t, 154 | &TestServerDriver{ 155 | Debug: false, 156 | Settings: &Settings{ 157 | EnableHASH: true, 158 | }, 159 | }, 160 | ) 161 | conf := goftp.Config{ 162 | User: authUser, 163 | Password: authPass, 164 | } 165 | 166 | client, err := goftp.DialConfig(conf, server.Addr()) 167 | require.NoError(t, err, "Couldn't connect") 168 | 169 | defer func() { panicOnError(client.Close()) }() 170 | 171 | raw, err := client.OpenRawConn() 172 | require.NoError(t, err, "Couldn't open raw connection") 173 | 174 | defer func() { require.NoError(t, raw.Close()) }() 175 | 176 | returnCode, message, err := raw.SendCommand("OPTS HASH") 177 | require.NoError(t, err) 178 | require.Equal(t, StatusOK, returnCode, message) 179 | require.Equal(t, "SHA-256", message) 180 | 181 | returnCode, message, err = raw.SendCommand("OPTS HASH MD5") 182 | require.NoError(t, err) 183 | require.Equal(t, StatusOK, returnCode) 184 | require.Equal(t, "MD5", message) 185 | 186 | returnCode, message, err = raw.SendCommand("OPTS HASH CRC-37") 187 | require.NoError(t, err) 188 | require.Equal(t, StatusSyntaxErrorParameters, returnCode) 189 | require.Equal(t, "Unknown algorithm, current selection not changed", message) 190 | 191 | returnCode, message, err = raw.SendCommand("OPTS HASH") 192 | require.NoError(t, err) 193 | require.Equal(t, StatusOK, returnCode) 194 | require.Equal(t, "MD5", message) 195 | 196 | // now disable hash support 197 | server.settings.EnableHASH = false 198 | 199 | returnCode, _, err = raw.SendCommand("OPTS HASH") 200 | require.NoError(t, err) 201 | require.Equal(t, StatusSyntaxErrorNotRecognised, returnCode) 202 | } 203 | 204 | func TestAVBL(t *testing.T) { 205 | server := NewTestServer(t, false) 206 | conf := goftp.Config{ 207 | User: authUser, 208 | Password: authPass, 209 | } 210 | client, err := goftp.DialConfig(conf, server.Addr()) 211 | require.NoError(t, err, "Couldn't connect") 212 | 213 | defer func() { panicOnError(client.Close()) }() 214 | 215 | raw, err := client.OpenRawConn() 216 | require.NoError(t, err, "Couldn't open raw connection") 217 | 218 | defer func() { require.NoError(t, raw.Close()) }() 219 | 220 | returnCode, response, err := raw.SendCommand("AVBL") 221 | require.NoError(t, err) 222 | require.Equal(t, StatusFileStatus, returnCode) 223 | require.Equal(t, "123", response) 224 | 225 | // a missing dir 226 | returnCode, _, err = raw.SendCommand("AVBL missing") 227 | require.NoError(t, err) 228 | require.Equal(t, StatusActionNotTaken, returnCode) 229 | 230 | // AVBL on a file path 231 | ftpUpload(t, client, createTemporaryFile(t, 10), "file") 232 | 233 | returnCode, response, err = raw.SendCommand("AVBL file") 234 | require.NoError(t, err) 235 | require.Equal(t, StatusActionNotTaken, returnCode) 236 | require.Equal(t, "/file: is not a directory", response) 237 | 238 | noavblDir, err := client.Mkdir("noavbl") 239 | require.NoError(t, err) 240 | 241 | returnCode, response, err = raw.SendCommand(fmt.Sprintf("AVBL %v", noavblDir)) 242 | require.NoError(t, err) 243 | require.Equal(t, StatusActionNotTaken, returnCode) 244 | require.Equal(t, fmt.Sprintf("Couldn't get space for path %v: %v", noavblDir, errAvblNotPermitted.Error()), response) 245 | } 246 | 247 | func TestQuit(t *testing.T) { 248 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 249 | Debug: false, 250 | TLS: true, 251 | }) 252 | conf := goftp.Config{ 253 | User: authUser, 254 | Password: authPass, 255 | TLSConfig: &tls.Config{ 256 | //nolint:gosec 257 | InsecureSkipVerify: true, 258 | }, 259 | TLSMode: goftp.TLSExplicit, 260 | } 261 | client, err := goftp.DialConfig(conf, server.Addr()) 262 | require.NoError(t, err, "Couldn't connect") 263 | 264 | defer func() { panicOnError(client.Close()) }() 265 | 266 | raw, err := client.OpenRawConn() 267 | require.NoError(t, err, "Couldn't open raw connection") 268 | 269 | defer func() { require.NoError(t, raw.Close()) }() 270 | 271 | rc, _, err := raw.SendCommand("QUIT") 272 | require.NoError(t, err) 273 | require.Equal(t, StatusClosingControlConn, rc) 274 | } 275 | 276 | func TestQuitWithCustomMessage(t *testing.T) { 277 | driver := &MesssageDriver{ 278 | TestServerDriver{ 279 | Debug: true, 280 | TLS: true, 281 | }, 282 | } 283 | driver.Init() 284 | server := NewTestServerWithDriver(t, driver) 285 | req := require.New(t) 286 | conf := goftp.Config{ 287 | User: authUser, 288 | Password: authPass, 289 | TLSConfig: &tls.Config{ 290 | //nolint:gosec 291 | InsecureSkipVerify: true, 292 | }, 293 | TLSMode: goftp.TLSExplicit, 294 | } 295 | c, err := goftp.DialConfig(conf, server.Addr()) 296 | req.NoError(err, "Couldn't connect") 297 | 298 | defer func() { panicOnError(c.Close()) }() 299 | 300 | raw, err := c.OpenRawConn() 301 | req.NoError(err, "Couldn't open raw connection") 302 | 303 | rc, msg, err := raw.SendCommand("QUIT") 304 | req.NoError(err) 305 | req.Equal(StatusClosingControlConn, rc) 306 | req.Equal("Sayonara, bye bye!", msg) 307 | } 308 | 309 | func TestQuitWithTransferInProgress(t *testing.T) { 310 | req := require.New(t) 311 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 312 | Debug: false, 313 | }) 314 | conf := goftp.Config{ 315 | User: authUser, 316 | Password: authPass, 317 | } 318 | client, err := goftp.DialConfig(conf, server.Addr()) 319 | require.NoError(t, err, "Couldn't connect") 320 | 321 | defer func() { panicOnError(client.Close()) }() 322 | 323 | raw, err := client.OpenRawConn() 324 | require.NoError(t, err, "Couldn't open raw connection") 325 | 326 | defer func() { require.NoError(t, raw.Close()) }() 327 | 328 | syncChannel := make(chan struct{}, 1) 329 | go 330 | // I don't see a pragmatic/good way to test this without forwarding the errors to the channel, 331 | // and thus losing the convenience of testify. 332 | //nolint:testifylint 333 | func() { 334 | defer close(syncChannel) 335 | 336 | dcGetter, err := raw.PrepareDataConn() //nolint:govet 337 | req.NoError(err) 338 | 339 | file := createTemporaryFile(t, 256*1024) 340 | fileName := filepath.Base(file.Name()) 341 | rc, response, err := raw.SendCommand(fmt.Sprintf("%s %s", "STOR", fileName)) 342 | req.NoError(err) 343 | req.Equal(StatusFileStatusOK, rc, response) 344 | 345 | dataConn, err := dcGetter() 346 | req.NoError(err) 347 | 348 | syncChannel <- struct{}{} 349 | // wait some more time to be sure we send the QUIT command before starting the file copy 350 | time.Sleep(100 * time.Millisecond) 351 | 352 | _, err = io.Copy(dataConn, file) 353 | req.NoError(err) 354 | 355 | err = dataConn.Close() 356 | req.NoError(err) 357 | }() 358 | 359 | // wait for the transfer to start 360 | <-syncChannel 361 | // we send a QUIT command after sending STOR and before the transfer ends. 362 | // We expect the transfer close response and then the QUIT response 363 | returnCode, _, err := raw.SendCommand("QUIT") 364 | req.NoError(err) 365 | req.Equal(StatusClosingDataConn, returnCode) 366 | 367 | returnCode, _, err = raw.ReadResponse() 368 | req.NoError(err) 369 | req.Equal(StatusClosingControlConn, returnCode) 370 | } 371 | 372 | func TestTYPE(t *testing.T) { 373 | server := NewTestServer(t, false) 374 | conf := goftp.Config{ 375 | User: authUser, 376 | Password: authPass, 377 | } 378 | 379 | client, err := goftp.DialConfig(conf, server.Addr()) 380 | require.NoError(t, err, "Couldn't connect") 381 | 382 | defer func() { panicOnError(client.Close()) }() 383 | 384 | raw, err := client.OpenRawConn() 385 | require.NoError(t, err, "Couldn't open raw connection") 386 | 387 | defer func() { require.NoError(t, raw.Close()) }() 388 | 389 | returnCode, _, err := raw.SendCommand("TYPE I") 390 | require.NoError(t, err) 391 | require.Equal(t, StatusOK, returnCode) 392 | 393 | returnCode, _, err = raw.SendCommand("TYPE A") 394 | require.NoError(t, err) 395 | require.Equal(t, StatusOK, returnCode) 396 | 397 | returnCode, _, err = raw.SendCommand("TYPE A N") 398 | require.NoError(t, err) 399 | require.Equal(t, StatusOK, returnCode) 400 | 401 | returnCode, _, err = raw.SendCommand("TYPE i") 402 | require.NoError(t, err) 403 | require.Equal(t, StatusOK, returnCode) 404 | 405 | returnCode, _, err = raw.SendCommand("TYPE a") 406 | require.NoError(t, err) 407 | require.Equal(t, StatusOK, returnCode) 408 | 409 | returnCode, _, err = raw.SendCommand("TYPE l 8") 410 | require.NoError(t, err) 411 | require.Equal(t, StatusOK, returnCode) 412 | 413 | returnCode, _, err = raw.SendCommand("TYPE l 7") 414 | require.NoError(t, err) 415 | require.Equal(t, StatusOK, returnCode) 416 | 417 | returnCode, _, err = raw.SendCommand("TYPE wrong") 418 | require.NoError(t, err) 419 | require.Equal(t, StatusNotImplementedParam, returnCode) 420 | } 421 | 422 | func TestMode(t *testing.T) { 423 | server := NewTestServer(t, false) 424 | conf := goftp.Config{ 425 | User: authUser, 426 | Password: authPass, 427 | } 428 | 429 | client, err := goftp.DialConfig(conf, server.Addr()) 430 | require.NoError(t, err, "Couldn't connect") 431 | 432 | defer func() { panicOnError(client.Close()) }() 433 | 434 | raw, err := client.OpenRawConn() 435 | require.NoError(t, err, "Couldn't open raw connection") 436 | 437 | returnCode, _, err := raw.SendCommand("MODE S") 438 | require.NoError(t, err) 439 | require.Equal(t, StatusOK, returnCode) 440 | 441 | returnCode, _, err = raw.SendCommand("MODE X") 442 | require.NoError(t, err) 443 | require.Equal(t, StatusNotImplementedParam, returnCode) 444 | } 445 | 446 | func TestREIN(t *testing.T) { 447 | server := NewTestServer(t, false) 448 | conf := goftp.Config{ 449 | User: authUser, 450 | Password: authPass, 451 | } 452 | 453 | client, err := goftp.DialConfig(conf, server.Addr()) 454 | require.NoError(t, err, "Couldn't connect") 455 | 456 | defer func() { panicOnError(client.Close()) }() 457 | 458 | raw, err := client.OpenRawConn() 459 | require.NoError(t, err, "Couldn't open raw connection") 460 | 461 | returnCode, _, err := raw.SendCommand("REIN") 462 | require.NoError(t, err) 463 | require.Equal(t, StatusCommandNotImplemented, returnCode) 464 | } 465 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) <2016> Andrew Arrow 4 | Copyright (c) <2016> Florent Clairambault 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in the 8 | Software without restriction, including without limitation the rights to use, copy, 9 | modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 10 | and to permit persons to whom the Software is furnished to do so, subject to the 11 | following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 17 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 21 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | // Package ftpserver provides all the tools to build your own FTP server: The core library and the driver. 2 | package ftpserver 3 | 4 | import ( 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "net" 9 | "syscall" 10 | "time" 11 | 12 | log "github.com/fclairamb/go-log" 13 | lognoop "github.com/fclairamb/go-log/noop" 14 | ) 15 | 16 | // ErrNotListening is returned when we are performing an action that is only valid while listening 17 | var ErrNotListening = errors.New("we aren't listening") 18 | 19 | // CommandDescription defines which function should be used and if it should be open to anyone or only logged in users 20 | type CommandDescription struct { 21 | Open bool // Open to clients without auth 22 | TransferRelated bool // This is a command that can open a transfer connection 23 | SpecialAction bool // Command to handle even if there is a transfer in progress 24 | Fn func(*clientHandler, string) error // Function to handle it 25 | } 26 | 27 | // This is shared between FtpServer instances as there's no point in making the FTP commands behave differently 28 | // between them. 29 | var commandsMap = map[string]*CommandDescription{ //nolint:gochecknoglobals 30 | // Authentication 31 | "USER": {Fn: (*clientHandler).handleUSER, Open: true}, 32 | "PASS": {Fn: (*clientHandler).handlePASS, Open: true}, 33 | "ACCT": {Fn: (*clientHandler).handleNotImplemented}, 34 | "ADAT": {Fn: (*clientHandler).handleNotImplemented}, 35 | 36 | // TLS handling 37 | "AUTH": {Fn: (*clientHandler).handleAUTH, Open: true}, 38 | "PROT": {Fn: (*clientHandler).handlePROT, Open: true}, 39 | "PBSZ": {Fn: (*clientHandler).handlePBSZ, Open: true}, 40 | "CCC": {Fn: (*clientHandler).handleNotImplemented}, 41 | "CONF": {Fn: (*clientHandler).handleNotImplemented}, 42 | "ENC": {Fn: (*clientHandler).handleNotImplemented}, 43 | "MIC": {Fn: (*clientHandler).handleNotImplemented}, 44 | 45 | // Misc 46 | "CLNT": {Fn: (*clientHandler).handleCLNT, Open: true}, 47 | "FEAT": {Fn: (*clientHandler).handleFEAT, Open: true}, 48 | "SYST": {Fn: (*clientHandler).handleSYST, Open: true}, 49 | "NOOP": {Fn: (*clientHandler).handleNOOP, Open: true}, 50 | "OPTS": {Fn: (*clientHandler).handleOPTS, Open: true}, 51 | "QUIT": {Fn: (*clientHandler).handleQUIT, Open: true, SpecialAction: true}, 52 | "AVBL": {Fn: (*clientHandler).handleAVBL}, 53 | "ABOR": {Fn: (*clientHandler).handleABOR, SpecialAction: true}, 54 | "CSID": {Fn: (*clientHandler).handleNotImplemented}, 55 | "HELP": {Fn: (*clientHandler).handleNotImplemented}, 56 | "HOST": {Fn: (*clientHandler).handleNotImplemented}, 57 | "LANG": {Fn: (*clientHandler).handleNotImplemented}, 58 | "XRSQ": {Fn: (*clientHandler).handleNotImplemented}, 59 | "XSEM": {Fn: (*clientHandler).handleNotImplemented}, 60 | "XSEN": {Fn: (*clientHandler).handleNotImplemented}, 61 | 62 | // File access 63 | "SIZE": {Fn: (*clientHandler).handleSIZE}, 64 | "DSIZ": {Fn: (*clientHandler).handleNotImplemented}, 65 | "STAT": {Fn: (*clientHandler).handleSTAT, SpecialAction: true}, 66 | "MDTM": {Fn: (*clientHandler).handleMDTM}, 67 | "MFMT": {Fn: (*clientHandler).handleMFMT}, 68 | "MFF": {Fn: (*clientHandler).handleNotImplemented}, 69 | "MFCT": {Fn: (*clientHandler).handleNotImplemented}, 70 | "RETR": {Fn: (*clientHandler).handleRETR, TransferRelated: true}, 71 | "STOR": {Fn: (*clientHandler).handleSTOR, TransferRelated: true}, 72 | "STOU": {Fn: (*clientHandler).handleNotImplemented}, 73 | "STRU": {Fn: (*clientHandler).handleNotImplemented}, 74 | "APPE": {Fn: (*clientHandler).handleAPPE, TransferRelated: true}, 75 | "DELE": {Fn: (*clientHandler).handleDELE}, 76 | "RNFR": {Fn: (*clientHandler).handleRNFR}, 77 | "RNTO": {Fn: (*clientHandler).handleRNTO}, 78 | "ALLO": {Fn: (*clientHandler).handleALLO}, 79 | "REST": {Fn: (*clientHandler).handleREST}, 80 | "SITE": {Fn: (*clientHandler).handleSITE}, 81 | "HASH": {Fn: (*clientHandler).handleHASH}, 82 | "XCRC": {Fn: (*clientHandler).handleCRC32}, 83 | "MD5": {Fn: (*clientHandler).handleMD5}, 84 | "XMD5": {Fn: (*clientHandler).handleMD5}, 85 | "XSHA": {Fn: (*clientHandler).handleSHA1}, 86 | "XSHA1": {Fn: (*clientHandler).handleSHA1}, 87 | "XSHA256": {Fn: (*clientHandler).handleSHA256}, 88 | "XSHA512": {Fn: (*clientHandler).handleSHA512}, 89 | "COMB": {Fn: (*clientHandler).handleCOMB}, 90 | "THMB": {Fn: (*clientHandler).handleNotImplemented}, 91 | "XRCP": {Fn: (*clientHandler).handleNotImplemented}, 92 | 93 | // Directory handling 94 | "CWD": {Fn: (*clientHandler).handleCWD}, 95 | "PWD": {Fn: (*clientHandler).handlePWD}, 96 | "XCWD": {Fn: (*clientHandler).handleCWD}, 97 | "XPWD": {Fn: (*clientHandler).handlePWD}, 98 | "CDUP": {Fn: (*clientHandler).handleCDUP}, 99 | "NLST": {Fn: (*clientHandler).handleNLST, TransferRelated: true}, 100 | "LIST": {Fn: (*clientHandler).handleLIST, TransferRelated: true}, 101 | "MLSD": {Fn: (*clientHandler).handleMLSD, TransferRelated: true}, 102 | "MLST": {Fn: (*clientHandler).handleMLST}, 103 | "MKD": {Fn: (*clientHandler).handleMKD}, 104 | "RMD": {Fn: (*clientHandler).handleRMD}, 105 | "RMDA": {Fn: (*clientHandler).handleNotImplemented}, 106 | "XMKD": {Fn: (*clientHandler).handleMKD}, 107 | "XRMD": {Fn: (*clientHandler).handleRMD}, 108 | "SMNT": {Fn: (*clientHandler).handleNotImplemented}, 109 | "XCUP": {Fn: (*clientHandler).handleNotImplemented}, 110 | 111 | // Connection handling 112 | "TYPE": {Fn: (*clientHandler).handleTYPE}, 113 | "MODE": {Fn: (*clientHandler).handleMODE}, 114 | "PASV": {Fn: (*clientHandler).handlePASV}, 115 | "EPSV": {Fn: (*clientHandler).handlePASV}, 116 | "LPSV": {Fn: (*clientHandler).handleNotImplemented}, 117 | "SPSV": {Fn: (*clientHandler).handleNotImplemented}, 118 | "PORT": {Fn: (*clientHandler).handlePORT}, 119 | "LRPT": {Fn: (*clientHandler).handleNotImplemented}, 120 | "EPRT": {Fn: (*clientHandler).handlePORT}, 121 | "REIN": {Fn: (*clientHandler).handleNotImplemented}, 122 | } 123 | 124 | var specialAttentionCommands = []string{"ABOR", "STAT", "QUIT"} //nolint:gochecknoglobals 125 | 126 | // FtpServer is where everything is stored 127 | // We want to keep it as simple as possible 128 | type FtpServer struct { 129 | Logger log.Logger // fclairamb/go-log generic logger 130 | settings *Settings // General settings 131 | listener net.Listener // listener used to receive files 132 | clientCounter uint32 // Clients counter 133 | driver MainDriver // Driver to handle the client authentication and the file access driver selection 134 | } 135 | 136 | func (server *FtpServer) loadSettings() error { 137 | settings, err := server.driver.GetSettings() 138 | 139 | if err != nil || settings == nil { 140 | return newDriverError("couldn't load settings", err) 141 | } 142 | 143 | if settings.PublicHost != "" { 144 | settings.PublicHost, err = parseIPv4(settings.PublicHost) 145 | if err != nil { 146 | return err 147 | } 148 | } 149 | 150 | if settings.Listener == nil && settings.ListenAddr == "" { 151 | settings.ListenAddr = "0.0.0.0:2121" 152 | } 153 | 154 | // florent(2018-01-14): #58: IDLE timeout: Default idle timeout will be set at 900 seconds 155 | if settings.IdleTimeout == 0 { 156 | settings.IdleTimeout = 900 157 | } 158 | 159 | if settings.ConnectionTimeout == 0 { 160 | settings.ConnectionTimeout = 30 161 | } 162 | 163 | if settings.Banner == "" { 164 | settings.Banner = "ftpserver - golang FTP server" 165 | } 166 | 167 | server.settings = settings 168 | 169 | return nil 170 | } 171 | 172 | func parseIPv4(publicHost string) (string, error) { 173 | parsedIP := net.ParseIP(publicHost) 174 | if parsedIP == nil { 175 | return "", &ipValidationError{error: fmt.Sprintf("invalid passive IP %#v", publicHost)} 176 | } 177 | 178 | parsedIP = parsedIP.To4() 179 | if parsedIP == nil { 180 | return "", &ipValidationError{error: fmt.Sprintf("invalid IPv4 passive IP %#v", publicHost)} 181 | } 182 | 183 | return parsedIP.String(), nil 184 | } 185 | 186 | // Listen starts the listening 187 | // It's not a blocking call 188 | func (server *FtpServer) Listen() error { 189 | err := server.loadSettings() 190 | if err != nil { 191 | return fmt.Errorf("could not load settings: %w", err) 192 | } 193 | 194 | // The driver can provide its own listener implementation 195 | if server.settings.Listener != nil { 196 | server.listener = server.settings.Listener 197 | } else { 198 | // Otherwise, it's what we currently use 199 | server.listener, err = server.createListener() 200 | if err != nil { 201 | return fmt.Errorf("could not create listener: %w", err) 202 | } 203 | } 204 | 205 | server.Logger.Info("Listening...", "address", server.listener.Addr()) 206 | 207 | return nil 208 | } 209 | 210 | func (server *FtpServer) createListener() (net.Listener, error) { 211 | listener, err := net.Listen("tcp", server.settings.ListenAddr) 212 | if err != nil { 213 | server.Logger.Error("cannot listen on main port", "err", err, "listenAddr", server.settings.ListenAddr) 214 | 215 | return nil, newNetworkError("cannot listen on main port", err) 216 | } 217 | 218 | if server.settings.TLSRequired == ImplicitEncryption { 219 | // implicit TLS 220 | var tlsConfig *tls.Config 221 | 222 | tlsConfig, err = server.driver.GetTLSConfig() 223 | if err != nil || tlsConfig == nil { 224 | server.Logger.Error("Cannot get tls config", "err", err) 225 | 226 | return nil, newDriverError("cannot get tls config", err) 227 | } 228 | 229 | listener = tls.NewListener(listener, tlsConfig) 230 | } 231 | 232 | return listener, nil 233 | } 234 | 235 | func temporaryError(err net.Error) bool { 236 | if syscallErrNo := new(syscall.Errno); errors.As(err, syscallErrNo) { 237 | if *syscallErrNo == syscall.ECONNABORTED || *syscallErrNo == syscall.ECONNRESET { 238 | return true 239 | } 240 | } 241 | 242 | return false 243 | } 244 | 245 | // Serve accepts and processes any new incoming client 246 | func (server *FtpServer) Serve() error { 247 | var tempDelay time.Duration // how long to sleep on accept failure 248 | 249 | for { 250 | connection, err := server.listener.Accept() 251 | if err != nil { 252 | if ok, finalErr := server.handleAcceptError(err, &tempDelay); ok { 253 | return finalErr 254 | } 255 | 256 | continue 257 | } 258 | 259 | tempDelay = 0 260 | 261 | server.clientArrival(connection) 262 | } 263 | } 264 | 265 | // handleAcceptError handles the error that occurred when accepting a new connection 266 | // It returns a boolean indicating if the error should stop the server and the error itself or none if it's a standard 267 | // scenario (e.g. a closed listener) 268 | func (server *FtpServer) handleAcceptError(err error, tempDelay *time.Duration) (bool, error) { 269 | server.Logger.Error("Serve error", "err", err) 270 | 271 | if errOp := (&net.OpError{}); errors.As(err, &errOp) { 272 | // This means we just closed the connection and it's OK 273 | if errOp.Err.Error() == "use of closed network connection" { 274 | server.listener = nil 275 | 276 | return true, nil 277 | } 278 | } 279 | 280 | // see https://github.com/golang/go/blob/4aa1efed4853ea067d665a952eee77c52faac774/src/net/http/server.go#L3046 281 | // & https://github.com/fclairamb/ftpserverlib/pull/352#pullrequestreview-1077459896 282 | // The temporaryError method should replace net.Error.Temporary() when the go team 283 | // will have provided us a better way to detect temporary errors. 284 | var ne net.Error 285 | if errors.As(err, &ne) && ne.Temporary() { //nolint:staticcheck 286 | if *tempDelay == 0 { 287 | *tempDelay = 5 * time.Millisecond 288 | } else { 289 | *tempDelay *= 2 290 | } 291 | 292 | if max := 1 * time.Second; *tempDelay > max { 293 | *tempDelay = max 294 | } 295 | 296 | server.Logger.Warn( 297 | "accept error", err, 298 | "retry delay", tempDelay) 299 | time.Sleep(*tempDelay) 300 | 301 | return false, nil 302 | } 303 | 304 | server.Logger.Error("Listener accept error", "err", err) 305 | 306 | return true, newNetworkError("listener accept error", err) 307 | } 308 | 309 | // ListenAndServe simply chains the Listen and Serve method calls 310 | func (server *FtpServer) ListenAndServe() error { 311 | if err := server.Listen(); err != nil { 312 | return err 313 | } 314 | 315 | server.Logger.Info("Starting...") 316 | 317 | return server.Serve() 318 | } 319 | 320 | // NewFtpServer creates a new FtpServer instance 321 | func NewFtpServer(driver MainDriver) *FtpServer { 322 | return &FtpServer{ 323 | driver: driver, 324 | Logger: lognoop.NewNoOpLogger(), 325 | } 326 | } 327 | 328 | // Addr shows the listening address 329 | func (server *FtpServer) Addr() string { 330 | if server.listener != nil { 331 | return server.listener.Addr().String() 332 | } 333 | 334 | return "" 335 | } 336 | 337 | // Stop closes the listener 338 | func (server *FtpServer) Stop() error { 339 | if server.listener == nil { 340 | return ErrNotListening 341 | } 342 | 343 | if err := server.listener.Close(); err != nil { 344 | server.Logger.Warn( 345 | "Could not close listener", 346 | "err", err, 347 | ) 348 | 349 | return newNetworkError("couln't close listener", err) 350 | } 351 | 352 | return nil 353 | } 354 | 355 | // When a client connects, the server could refuse the connection 356 | func (server *FtpServer) clientArrival(conn net.Conn) { 357 | server.clientCounter++ 358 | id := server.clientCounter 359 | 360 | c := server.newClientHandler(conn, id, server.settings.DefaultTransferType) 361 | go c.HandleCommands() 362 | 363 | c.logger.Debug("Client connected", "clientIp", conn.RemoteAddr()) 364 | } 365 | 366 | // clientDeparture 367 | func (server *FtpServer) clientDeparture(c *clientHandler) { 368 | c.logger.Debug("Client disconnected", "clientIp", c.conn.RemoteAddr()) 369 | } 370 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "os" 7 | "syscall" 8 | "testing" 9 | "time" 10 | 11 | lognoop "github.com/fclairamb/go-log/noop" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestMain(m *testing.M) { 16 | // we change the timezone to be able to test that MLSD/MLST commands write UTC timestamps 17 | loc, err := time.LoadLocation("America/New_York") 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | time.Local = loc 23 | 24 | os.Exit(m.Run()) 25 | } 26 | 27 | var errListenerAccept = errors.New("error accepting a connection") 28 | 29 | type fakeNetError struct { 30 | error 31 | count int 32 | } 33 | 34 | func (e *fakeNetError) Timeout() bool { 35 | return false 36 | } 37 | 38 | func (e *fakeNetError) Temporary() bool { 39 | e.count++ 40 | 41 | return e.count < 10 42 | } 43 | 44 | func (e *fakeNetError) Error() string { 45 | return e.error.Error() 46 | } 47 | 48 | type fakeListener struct { 49 | server net.Conn 50 | client net.Conn 51 | err error 52 | } 53 | 54 | func (l *fakeListener) Accept() (net.Conn, error) { 55 | return l.client, l.err 56 | } 57 | 58 | func (l *fakeListener) Close() error { 59 | errClient := l.client.Close() 60 | errServer := l.server.Close() 61 | 62 | if errServer != nil { 63 | return errServer 64 | } 65 | 66 | return errClient 67 | } 68 | 69 | func (l *fakeListener) Addr() net.Addr { 70 | return l.server.LocalAddr() 71 | } 72 | 73 | func newFakeListener(err error) net.Listener { 74 | server, client := net.Pipe() 75 | 76 | return &fakeListener{ 77 | server: server, 78 | client: client, 79 | err: err, 80 | } 81 | } 82 | 83 | func TestCannotListen(t *testing.T) { 84 | req := require.New(t) 85 | 86 | portBlockerListener, err := net.Listen("tcp", "127.0.0.1:0") 87 | req.NoError(err) 88 | 89 | defer func() { req.NoError(portBlockerListener.Close()) }() 90 | 91 | server := FtpServer{ 92 | Logger: lognoop.NewNoOpLogger(), 93 | driver: &TestServerDriver{ 94 | Settings: &Settings{ 95 | ListenAddr: portBlockerListener.Addr().String(), 96 | }, 97 | }, 98 | } 99 | 100 | err = server.Listen() 101 | var ne NetworkError 102 | req.ErrorAs(err, &ne) 103 | req.Equal("cannot listen on main port", ne.str) 104 | } 105 | 106 | func TestListenWithBadTLSSettings(t *testing.T) { 107 | req := require.New(t) 108 | 109 | portBlockerListener, err := net.Listen("tcp", "127.0.0.1:0") 110 | req.NoError(err) 111 | 112 | defer func() { req.NoError(portBlockerListener.Close()) }() 113 | 114 | server := FtpServer{ 115 | Logger: lognoop.NewNoOpLogger(), 116 | driver: &TestServerDriver{ 117 | Settings: &Settings{ 118 | TLSRequired: ImplicitEncryption, 119 | }, 120 | TLS: false, 121 | }, 122 | } 123 | 124 | err = server.Listen() 125 | var drvErr DriverError 126 | req.ErrorAs(err, &drvErr) 127 | req.Equal("cannot get tls config", drvErr.str) 128 | } 129 | 130 | func TestListenerAcceptErrors(t *testing.T) { 131 | errNetFake := &fakeNetError{error: errListenerAccept} 132 | 133 | server := FtpServer{ 134 | listener: newFakeListener(errNetFake), 135 | Logger: lognoop.NewNoOpLogger(), 136 | } 137 | err := server.Serve() 138 | require.ErrorContains(t, err, errListenerAccept.Error()) 139 | } 140 | 141 | func TestPortCommandFormatOK(t *testing.T) { 142 | net, err := parsePORTAddr("127,0,0,1,239,163") 143 | require.NoError(t, err, "Problem parsing") 144 | require.Equal(t, "127.0.0.1", net.IP.String(), "Problem parsing IP") 145 | require.Equal(t, 239<<8+163, net.Port, "Problem parsing port") 146 | } 147 | 148 | func TestPortCommandFormatInvalid(t *testing.T) { 149 | badFormats := []string{ 150 | "127,0,0,1,239,", 151 | "127,0,0,1,1,1,1", 152 | } 153 | for _, f := range badFormats { 154 | _, err := parsePORTAddr(f) 155 | require.Error(t, err, "This should have failed") 156 | } 157 | } 158 | 159 | func TestQuoteDoubling(t *testing.T) { 160 | type args struct { 161 | s string 162 | } 163 | 164 | tests := []struct { 165 | name string 166 | args args 167 | want string 168 | }{ 169 | {"1", args{" white space"}, " white space"}, 170 | {"1", args{` one" quote`}, ` one"" quote`}, 171 | {"1", args{` two"" quote`}, ` two"""" quote`}, 172 | } 173 | 174 | for _, tt := range tests { 175 | tt := tt 176 | t.Run(tt.name, func(t *testing.T) { 177 | require.Equal(t, tt.want, quoteDoubling(tt.args.s)) 178 | }) 179 | } 180 | } 181 | 182 | func TestServerSettingsIPError(t *testing.T) { 183 | server := FtpServer{ 184 | Logger: lognoop.NewNoOpLogger(), 185 | } 186 | 187 | t.Run("IPv4 with 3 numbers", func(t *testing.T) { 188 | server.driver = &TestServerDriver{ 189 | Settings: &Settings{ 190 | PublicHost: "127.0.0", 191 | }, 192 | } 193 | 194 | err := server.loadSettings() 195 | _, ok := err.(*ipValidationError) //nolint:errorlint // Here we want to test the exact error match 196 | require.True(t, ok) 197 | }) 198 | 199 | t.Run("localhost public host", func(t *testing.T) { 200 | server.driver = &TestServerDriver{ 201 | Settings: &Settings{ 202 | PublicHost: "::1", 203 | }, 204 | } 205 | 206 | err := server.loadSettings() 207 | _, ok := err.(*ipValidationError) //nolint:errorlint // Here we want to test the exact error match 208 | require.True(t, ok) 209 | }) 210 | 211 | t.Run("Strangely looking IPv6/IPv4 address", func(t *testing.T) { 212 | server.driver = &TestServerDriver{ 213 | Settings: &Settings{ 214 | PublicHost: "::ffff:192.168.1.1", 215 | }, 216 | } 217 | err := server.loadSettings() 218 | require.NoError(t, err) 219 | require.Equal(t, "192.168.1.1", server.settings.PublicHost) 220 | }) 221 | } 222 | 223 | func TestServerSettingsNilSettings(t *testing.T) { 224 | req := require.New(t) 225 | server := FtpServer{ 226 | Logger: lognoop.NewNoOpLogger(), 227 | driver: &TestServerDriver{ 228 | Settings: nil, 229 | }, 230 | } 231 | 232 | err := server.loadSettings() 233 | req.Error(err) 234 | 235 | drvErr := DriverError{} 236 | req.ErrorAs(err, &drvErr) 237 | req.ErrorContains(drvErr, "couldn't load settings") 238 | } 239 | 240 | func TestTemporaryError(t *testing.T) { 241 | req := require.New(t) 242 | 243 | // Test the temporaryError function 244 | req.False(temporaryError(nil)) 245 | req.False(temporaryError(&fakeNetError{error: errListenerAccept})) 246 | req.False(temporaryError(&net.OpError{ 247 | Err: &fakeNetError{error: errListenerAccept}, 248 | })) 249 | 250 | for _, serr := range []syscall.Errno{syscall.ECONNABORTED, syscall.ECONNRESET} { 251 | req.True(temporaryError(&net.OpError{Err: &os.SyscallError{Err: serr}})) 252 | } 253 | 254 | req.False(temporaryError(&net.OpError{Err: &os.SyscallError{Err: syscall.EAGAIN}})) 255 | } 256 | -------------------------------------------------------------------------------- /transfer_active.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | func (c *clientHandler) handlePORT(param string) error { 15 | command := c.GetLastCommand() 16 | 17 | if c.server.settings.DisableActiveMode { 18 | c.writeMessage(StatusServiceNotAvailable, fmt.Sprintf("%v command is disabled", command)) 19 | 20 | return nil 21 | } 22 | 23 | var err error 24 | var raddr *net.TCPAddr 25 | 26 | if command == "EPRT" { 27 | raddr, err = parseEPRTAddr(param) 28 | } else { // PORT 29 | raddr, err = parsePORTAddr(param) 30 | } 31 | 32 | if err != nil { 33 | c.writeMessage(StatusSyntaxErrorParameters, fmt.Sprintf("Problem parsing %s: %v", param, err)) 34 | 35 | return nil 36 | } 37 | 38 | err = c.checkDataConnectionRequirement(raddr.IP, DataChannelActive) 39 | if err != nil { 40 | // we don't want to expose the full error to the client, we just log it 41 | c.logger.Warn("Could not validate active data connection requirement", "err", err) 42 | c.writeMessage(StatusSyntaxErrorParameters, "Your request does not meet "+ 43 | "the configured security requirements") 44 | 45 | return nil 46 | } 47 | 48 | var tlsConfig *tls.Config 49 | 50 | if c.HasTLSForTransfers() || c.server.settings.TLSRequired == ImplicitEncryption { 51 | tlsConfig, err = c.server.driver.GetTLSConfig() 52 | if err != nil { 53 | c.writeMessage(StatusServiceNotAvailable, fmt.Sprintf("Cannot get a TLS config for active connection: %v", err)) 54 | 55 | return nil 56 | } 57 | } 58 | 59 | c.transferMu.Lock() 60 | 61 | c.transfer = &activeTransferHandler{ 62 | raddr: raddr, 63 | settings: c.server.settings, 64 | tlsConfig: tlsConfig, 65 | } 66 | 67 | c.transferMu.Unlock() 68 | c.setLastDataChannel(DataChannelActive) 69 | 70 | c.writeMessage(StatusOK, command+" command successful") 71 | 72 | return nil 73 | } 74 | 75 | // Active connection 76 | type activeTransferHandler struct { 77 | raddr *net.TCPAddr // Remote address of the client 78 | conn net.Conn // Connection used to connect to him 79 | settings *Settings // Settings 80 | tlsConfig *tls.Config // not nil if the active connection requires TLS 81 | info string // transfer info 82 | } 83 | 84 | func (a *activeTransferHandler) GetInfo() string { 85 | return a.info 86 | } 87 | 88 | func (a *activeTransferHandler) SetInfo(info string) { 89 | a.info = info 90 | } 91 | 92 | func (a *activeTransferHandler) Open() (net.Conn, error) { 93 | timeout := time.Duration(time.Second.Nanoseconds() * int64(a.settings.ConnectionTimeout)) 94 | dialer := &net.Dialer{Timeout: timeout} 95 | 96 | if !a.settings.ActiveTransferPortNon20 { 97 | dialer.LocalAddr, _ = net.ResolveTCPAddr("tcp", ":20") 98 | dialer.Control = Control 99 | } 100 | 101 | conn, err := dialer.Dial("tcp", a.raddr.String()) 102 | if err != nil { 103 | return nil, newNetworkError("could not establish active connection", err) 104 | } 105 | 106 | if a.tlsConfig != nil { 107 | conn = tls.Server(conn, a.tlsConfig) 108 | } 109 | 110 | // keep connection as it will be closed by Close() 111 | a.conn = conn 112 | 113 | return a.conn, nil 114 | } 115 | 116 | // Close closes only if connection is established 117 | func (a *activeTransferHandler) Close() error { 118 | if a.conn != nil { 119 | if err := a.conn.Close(); err != nil { 120 | return newNetworkError("could not close active connection", err) 121 | } 122 | } 123 | 124 | return nil 125 | } 126 | 127 | var remoteAddrRegex = regexp.MustCompile(`^([0-9]{1,3},){5}[0-9]{1,3}$`) 128 | 129 | // ErrRemoteAddrFormat is returned when the remote address has a bad format 130 | var ErrRemoteAddrFormat = errors.New("remote address has a bad format") 131 | 132 | // parsePORTAddr parses remote address of the client from param. This address 133 | // is used for establishing a connection with the client. 134 | // 135 | // Param Format: 192,168,150,80,14,178 136 | // Host: 192.168.150.80 137 | // Port: (14 * 256) + 148 138 | func parsePORTAddr(param string) (*net.TCPAddr, error) { 139 | if !remoteAddrRegex.MatchString(param) { 140 | return nil, fmt.Errorf("could not parse %s: %w", param, ErrRemoteAddrFormat) 141 | } 142 | 143 | params := strings.Split(param, ",") 144 | 145 | ipParts := strings.Join(params[0:4], ".") 146 | 147 | portByte1, err := strconv.Atoi(params[4]) 148 | if err != nil { 149 | return nil, ErrRemoteAddrFormat 150 | } 151 | 152 | portByte2, err := strconv.Atoi(params[5]) 153 | if err != nil { 154 | return nil, ErrRemoteAddrFormat 155 | } 156 | 157 | port := portByte1<<8 + portByte2 158 | 159 | addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", ipParts, port)) 160 | if err != nil { 161 | err = newNetworkError("could not resolve "+param, err) 162 | } 163 | 164 | return addr, err 165 | } 166 | 167 | // Parse EPRT parameter. Full EPRT command format: 168 | // - IPv4 : "EPRT |1|h1.h2.h3.h4|port|\r\n" 169 | // - IPv6 : "EPRT |2|h1::h2:h3:h4:h5|port|\r\n" 170 | func parseEPRTAddr(param string) (*net.TCPAddr, error) { 171 | var addr *net.TCPAddr 172 | var err error 173 | 174 | params := strings.Split(param, "|") 175 | if len(params) != 5 { 176 | return nil, ErrRemoteAddrFormat 177 | } 178 | 179 | netProtocol := params[1] 180 | remoteIP := params[2] 181 | remotePort := params[3] 182 | 183 | // check port is valid 184 | var portI int 185 | if portI, err = strconv.Atoi(remotePort); err != nil || portI <= 0 || portI > 65535 { 186 | return nil, ErrRemoteAddrFormat 187 | } 188 | 189 | var ipAddress net.IP 190 | 191 | switch netProtocol { 192 | case "1", "2": 193 | // use protocol 1 means IPv4. 2 means IPv6 194 | // net.ParseIP for validate IP 195 | if ipAddress = net.ParseIP(remoteIP); ipAddress == nil { 196 | return nil, ErrRemoteAddrFormat 197 | } 198 | default: 199 | // wrong network protocol 200 | return nil, ErrRemoteAddrFormat 201 | } 202 | 203 | addr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(ipAddress.String(), strconv.Itoa(portI))) 204 | if err != nil { 205 | err = newNetworkError(fmt.Sprintf("could not resolve addr %v:%v", ipAddress, portI), err) 206 | } 207 | 208 | return addr, err 209 | } 210 | -------------------------------------------------------------------------------- /transfer_active_test.go: -------------------------------------------------------------------------------- 1 | // Package ftpserver provides all the tools to build your own FTP server: The core library and the driver. 2 | package ftpserver 3 | 4 | import ( 5 | "net" 6 | "regexp" 7 | "testing" 8 | 9 | "github.com/secsy/goftp" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func testRegexMatch(t *testing.T, regexp *regexp.Regexp, strings []string, expectedMatch bool) { 14 | t.Helper() 15 | 16 | for _, s := range strings { 17 | if regexp.MatchString(s) != expectedMatch { 18 | t.Errorf("Invalid match result: %s", s) 19 | } 20 | } 21 | } 22 | 23 | func TestRemoteAddrFormat(t *testing.T) { 24 | testRegexMatch(t, remoteAddrRegex, []string{"1,2,3,4,5,6"}, true) 25 | testRegexMatch(t, remoteAddrRegex, []string{"1,2,3,4,5"}, false) 26 | } 27 | 28 | func TestActiveTransferFromPort20(t *testing.T) { 29 | listener, err := net.Listen("tcp", ":20") //nolint:gosec 30 | if err != nil { 31 | t.Skipf("Binding on port 20 is not supported here: %v", err) 32 | } 33 | 34 | err = listener.Close() 35 | require.NoError(t, err) 36 | 37 | server := NewTestServerWithTestDriver(t, &TestServerDriver{ 38 | Debug: false, 39 | Settings: &Settings{ 40 | ActiveTransferPortNon20: false, 41 | }, 42 | }) 43 | 44 | conf := goftp.Config{ 45 | User: authUser, 46 | Password: authPass, 47 | ActiveTransfers: true, 48 | } 49 | client, err := goftp.DialConfig(conf, server.Addr()) 50 | require.NoError(t, err, "Couldn't connect") 51 | 52 | defer func() { panicOnError(client.Close()) }() 53 | 54 | _, err = client.ReadDir("/") 55 | require.NoError(t, err) 56 | 57 | // the second ReadDir fails if we don't se the SO_REUSEPORT/SO_REUSEADDR socket options 58 | _, err = client.ReadDir("/") 59 | require.NoError(t, err) 60 | } 61 | -------------------------------------------------------------------------------- /transfer_pasv.go: -------------------------------------------------------------------------------- 1 | package ftpserver 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "fmt" 7 | "math/rand" 8 | "net" 9 | "strings" 10 | "time" 11 | 12 | log "github.com/fclairamb/go-log" 13 | ) 14 | 15 | // Active/Passive transfer connection handler 16 | type transferHandler interface { 17 | // Get the connection to transfer data on 18 | Open() (net.Conn, error) 19 | 20 | // Close the connection (and any associated resource) 21 | Close() error 22 | 23 | // Set info about the transfer to return in STAT response 24 | SetInfo(info string) 25 | // Info about the transfer to return in STAT response 26 | GetInfo() string 27 | } 28 | 29 | // Passive connection 30 | type passiveTransferHandler struct { 31 | listener net.Listener // TCP or SSL Listener 32 | tcpListener *net.TCPListener // TCP Listener (only keeping it to define a deadline during the accept) 33 | Port int // TCP Port we are listening on 34 | connection net.Conn // TCP Connection established 35 | settings *Settings // Settings 36 | info string // transfer info 37 | logger log.Logger // Logger 38 | // data connection requirement checker 39 | checkDataConn func(dataConnIP net.IP, channelType DataChannel) error 40 | } 41 | 42 | type ipValidationError struct { 43 | error string 44 | } 45 | 46 | func (e *ipValidationError) Error() string { 47 | return e.error 48 | } 49 | 50 | func (c *clientHandler) getCurrentIP() ([]string, error) { 51 | // Provide our external IP address so the ftp client can connect back to us 52 | ipParts := c.server.settings.PublicHost 53 | 54 | // If we don't have an IP address, we can take the one that was used for the current connection 55 | if ipParts == "" { 56 | // Defer to the user-provided resolver. 57 | if c.server.settings.PublicIPResolver != nil { 58 | var err error 59 | ipParts, err = c.server.settings.PublicIPResolver(c) 60 | 61 | if err != nil { 62 | return nil, fmt.Errorf("couldn't fetch public IP: %w", err) 63 | } 64 | } else { 65 | ipParts = strings.Split(c.conn.LocalAddr().String(), ":")[0] 66 | } 67 | } 68 | 69 | quads := strings.Split(ipParts, ".") 70 | if len(quads) != 4 { 71 | c.logger.Warn("Invalid passive IP", "IP", ipParts) 72 | 73 | return nil, &ipValidationError{error: fmt.Sprintf("invalid passive IP %#v", ipParts)} 74 | } 75 | 76 | return quads, nil 77 | } 78 | 79 | // ErrNoAvailableListeningPort is returned when no port could be found to accept incoming connection 80 | var ErrNoAvailableListeningPort = errors.New("could not find any port to listen to") 81 | 82 | const ( 83 | portSearchMinAttempts = 10 84 | portSearchMaxAttempts = 1000 85 | ) 86 | 87 | func (c *clientHandler) findListenerWithinPortRange(portRange *PortRange) (*net.TCPListener, error) { 88 | nbAttempts := portRange.End - portRange.Start 89 | 90 | // Making sure we trying a reasonable amount of ports before giving up 91 | if nbAttempts < portSearchMinAttempts { 92 | nbAttempts = portSearchMinAttempts 93 | } else if nbAttempts > portSearchMaxAttempts { 94 | nbAttempts = portSearchMaxAttempts 95 | } 96 | 97 | for i := 0; i < nbAttempts; i++ { 98 | //nolint: gosec 99 | port := portRange.Start + rand.Intn(portRange.End-portRange.Start+1) 100 | laddr, errResolve := net.ResolveTCPAddr("tcp", fmt.Sprintf("0.0.0.0:%d", port)) 101 | 102 | if errResolve != nil { 103 | c.logger.Error("Problem resolving local port", "err", errResolve, "port", port) 104 | 105 | return nil, newNetworkError(fmt.Sprintf("could not resolve port %d", port), errResolve) 106 | } 107 | 108 | tcpListener, errListen := net.ListenTCP("tcp", laddr) 109 | if errListen == nil { 110 | return tcpListener, nil 111 | } 112 | } 113 | 114 | c.logger.Warn( 115 | "Could not find any free port", 116 | "nbAttempts", nbAttempts, 117 | "portRangeStart", portRange.Start, 118 | "portRAngeEnd", portRange.End, 119 | ) 120 | 121 | return nil, ErrNoAvailableListeningPort 122 | } 123 | 124 | func (c *clientHandler) handlePASV(_ string) error { 125 | command := c.GetLastCommand() 126 | addr, _ := net.ResolveTCPAddr("tcp", ":0") 127 | var tcpListener *net.TCPListener 128 | var err error 129 | portRange := c.server.settings.PassiveTransferPortRange 130 | 131 | if portRange != nil { 132 | tcpListener, err = c.findListenerWithinPortRange(portRange) 133 | } else { 134 | tcpListener, err = net.ListenTCP("tcp", addr) 135 | } 136 | 137 | if err != nil { 138 | c.logger.Error("Could not listen for passive connection", "err", err) 139 | c.writeMessage(StatusServiceNotAvailable, fmt.Sprintf("Could not listen for passive connection: %v", err)) 140 | 141 | return nil 142 | } 143 | // The listener will either be plain TCP or TLS 144 | var listener net.Listener 145 | listener = tcpListener 146 | 147 | if wrapper, ok := c.server.driver.(MainDriverExtensionPassiveWrapper); ok { 148 | listener, err = wrapper.WrapPassiveListener(listener) 149 | if err != nil { 150 | c.logger.Error("Could not wrap passive connection", "err", err) 151 | c.writeMessage(StatusServiceNotAvailable, fmt.Sprintf("Could not listen for passive connection: %v", err)) 152 | 153 | return nil 154 | } 155 | } 156 | 157 | if c.HasTLSForTransfers() || c.server.settings.TLSRequired == ImplicitEncryption { 158 | if tlsConfig, err := c.server.driver.GetTLSConfig(); err == nil { 159 | listener = tls.NewListener(listener, tlsConfig) 160 | } else { 161 | c.writeMessage(StatusServiceNotAvailable, fmt.Sprintf("Cannot get a TLS config: %v", err)) 162 | 163 | return nil 164 | } 165 | } 166 | 167 | transferHandler := &passiveTransferHandler{ //nolint:forcetypeassert 168 | tcpListener: tcpListener, 169 | listener: listener, 170 | Port: tcpListener.Addr().(*net.TCPAddr).Port, 171 | settings: c.server.settings, 172 | logger: c.logger, 173 | checkDataConn: c.checkDataConnectionRequirement, 174 | } 175 | 176 | // We should rewrite this part 177 | if command == "PASV" { 178 | if c.handlePassivePASV(transferHandler) { 179 | return nil 180 | } 181 | } else { 182 | c.writeMessage(StatusEnteringEPSV, fmt.Sprintf("Entering Extended Passive Mode (|||%d|)", transferHandler.Port)) 183 | } 184 | 185 | c.transferMu.Lock() 186 | if c.transfer != nil { 187 | c.transfer.Close() //nolint:errcheck,gosec 188 | } 189 | 190 | c.transfer = transferHandler 191 | c.transferMu.Unlock() 192 | c.setLastDataChannel(DataChannelPassive) 193 | 194 | return nil 195 | } 196 | 197 | func (c *clientHandler) handlePassivePASV(transferHandler *passiveTransferHandler) bool { 198 | portByte1 := transferHandler.Port / 256 199 | portByte2 := transferHandler.Port - (portByte1 * 256) 200 | quads, err2 := c.getCurrentIP() 201 | 202 | if err2 != nil { 203 | c.writeMessage(StatusServiceNotAvailable, fmt.Sprintf("Could not listen for passive connection: %v", err2)) 204 | 205 | return true 206 | } 207 | 208 | c.writeMessage( 209 | StatusEnteringPASV, 210 | fmt.Sprintf( 211 | "Entering Passive Mode (%s,%s,%s,%s,%d,%d)", 212 | quads[0], quads[1], quads[2], quads[3], 213 | portByte1, portByte2, 214 | ), 215 | ) 216 | 217 | return false 218 | } 219 | 220 | func (p *passiveTransferHandler) ConnectionWait(wait time.Duration) (net.Conn, error) { 221 | if p.connection == nil { 222 | var err error 223 | if err = p.tcpListener.SetDeadline(time.Now().Add(wait)); err != nil { 224 | return nil, fmt.Errorf("failed to set deadline: %w", err) 225 | } 226 | 227 | p.connection, err = p.listener.Accept() 228 | if err != nil { 229 | return nil, fmt.Errorf("failed to accept passive transfer connection: %w", err) 230 | } 231 | 232 | ipAddress, err := getIPFromRemoteAddr(p.connection.RemoteAddr()) 233 | if err != nil { 234 | p.logger.Warn("Could get remote passive IP address", "err", err) 235 | 236 | return nil, err 237 | } 238 | 239 | if err := p.checkDataConn(ipAddress, DataChannelPassive); err != nil { 240 | // we don't want to expose the full error to the client, we just log it 241 | p.logger.Warn("Could not validate passive data connection requirement", "err", err) 242 | 243 | return nil, &ipValidationError{error: "data connection security requirements not met"} 244 | } 245 | } 246 | 247 | return p.connection, nil 248 | } 249 | 250 | func (p *passiveTransferHandler) GetInfo() string { 251 | return p.info 252 | } 253 | 254 | func (p *passiveTransferHandler) SetInfo(info string) { 255 | p.info = info 256 | } 257 | 258 | func (p *passiveTransferHandler) Open() (net.Conn, error) { 259 | timeout := time.Duration(time.Second.Nanoseconds() * int64(p.settings.ConnectionTimeout)) 260 | 261 | return p.ConnectionWait(timeout) 262 | } 263 | 264 | // Closing only the client connection is not supported at that time 265 | func (p *passiveTransferHandler) Close() error { 266 | if p.tcpListener != nil { 267 | if err := p.tcpListener.Close(); err != nil { 268 | p.logger.Warn("Problem closing passive listener", "err", err) 269 | } 270 | } 271 | 272 | if p.connection != nil { 273 | if err := p.connection.Close(); err != nil { 274 | p.logger.Warn( 275 | "Problem closing passive connection", "err", err) 276 | } 277 | } 278 | 279 | return nil 280 | } 281 | --------------------------------------------------------------------------------