├── .drone.yml ├── .gitignore ├── MIT-LICENSE ├── README.markdown ├── auto ├── go-fmt ├── run-mem └── with-golang ├── bin └── .gtikeep ├── commands.go ├── commands_test.go ├── docker-compose.yml ├── ftpconn.go ├── ftpdatasocket.go ├── ftpdriver.go ├── ftpfileinfo.go ├── ftpfileinfo_test.go ├── ftplogger.go ├── ftpserver.go ├── ftpserver_test.go ├── go.mod ├── go.sum ├── graval-mem └── graval-mem.go ├── listformatter.go ├── listformatter_test.go └── scripts └── run-mem /.drone.yml: -------------------------------------------------------------------------------- 1 | image: go1.2 2 | env: 3 | - GOPATH=/var/cache/drone 4 | script: 5 | - go get -t 6 | - go build 7 | - go test -v 8 | 9 | notify: 10 | email: 11 | recipients: 12 | - paul@vdvreede.net 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 James Healy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # graval 2 | 3 | An experimental FTP server framework. By providing a simple driver class that 4 | responds to a handful of methods you can have a complete FTP server. 5 | 6 | Some sample use cases include persisting data to: 7 | 8 | * an Amazon S3 bucket 9 | * a relational database 10 | * redis 11 | * memory 12 | 13 | There is a sample in-memory driver available - see the usage instructions below 14 | for the steps to use it. 15 | 16 | Full documentation for the package is available on [godoc](http://godoc.org/github.com/yob/graval) 17 | 18 | ## Installation 19 | 20 | go get github.com/yob/graval 21 | 22 | ## Usage 23 | 24 | To boot an FTP server you will need to provide a driver that speaks to your 25 | persistence layer - the required driver contract is listed below. 26 | 27 | There is a sample in-memory driver available as a demo. You can build it with 28 | this command: 29 | 30 | go install github.com/yob/graval/graval-mem 31 | 32 | Then run it: 33 | 34 | ./bin/graval-mem 35 | 36 | And finally, connect to the server with any FTP client and the following 37 | details: 38 | 39 | host: 127.0.0.1 40 | username: test 41 | password: 1234 42 | 43 | ### The Driver Contract 44 | 45 | Your driver MUST implement a number of simple methods. You can view the required 46 | contract in the package docs on [godoc](http://godoc.org/github.com/yob/graval) 47 | 48 | ## Contributors 49 | 50 | * James Healy [http://www.yob.id.au](http://www.yob.id.au) 51 | 52 | ## Warning 53 | 54 | FTP is an incredibly insecure protocol. Be careful about forcing users to authenticate 55 | with a username or password that are important. 56 | 57 | ## License 58 | 59 | This library is distributed under the terms of the MIT License. See the included file for 60 | more detail. 61 | 62 | ## Contributing 63 | 64 | All suggestions and patches welcome, preferably via a git repository I can pull from. 65 | If this library proves useful to you, please let me know. 66 | 67 | ## Further Reading 68 | 69 | There are a range of RFCs that together specify the FTP protocol. In chronological 70 | order, the more useful ones are: 71 | 72 | * [http://tools.ietf.org/rfc/rfc959.txt](http://tools.ietf.org/rfc/rfc959.txt) 73 | * [http://tools.ietf.org/rfc/rfc1123.txt](http://tools.ietf.org/rfc/rfc1123.txt) 74 | * [http://tools.ietf.org/rfc/rfc2228.txt](http://tools.ietf.org/rfc/rfc2228.txt) 75 | * [http://tools.ietf.org/rfc/rfc2389.txt](http://tools.ietf.org/rfc/rfc2389.txt) 76 | * [http://tools.ietf.org/rfc/rfc2428.txt](http://tools.ietf.org/rfc/rfc2428.txt) 77 | * [http://tools.ietf.org/rfc/rfc3659.txt](http://tools.ietf.org/rfc/rfc3659.txt) 78 | * [http://tools.ietf.org/rfc/rfc4217.txt](http://tools.ietf.org/rfc/rfc4217.txt) 79 | 80 | For an english summary that's somewhat more legible than the RFCs, and provides 81 | some commentary on what features are actually useful or relevant 24 years after 82 | RFC959 was published: 83 | 84 | * [http://cr.yp.to/ftp.html](http://cr.yp.to/ftp.html) 85 | 86 | For a history lesson, check out Appendix III of RCF959. It lists the preceding 87 | (obsolete) RFC documents that relate to file transfers, including the ye old 88 | RFC114 from 1971, "A File Transfer Protocol" 89 | 90 | This library is heavily based on [em-ftpd](https://github.com/yob/em-ftpd), an FTPd 91 | framework with similar design goals within the ruby and EventMachine ecosystems. It 92 | worked well enough, but you know, callbacks and event loops make me something 93 | something. 94 | -------------------------------------------------------------------------------- /auto/go-fmt: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | $(dirname $0)/with-golang go fmt ./... 4 | -------------------------------------------------------------------------------- /auto/run-mem: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | $(dirname $0)/with-golang go build -o bin/graval-mem ./graval-mem 4 | ./bin/graval-mem 5 | -------------------------------------------------------------------------------- /auto/with-golang: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | # 3 | # Operate in environment suitable for running go 4 | 5 | cd $(dirname $0)/.. 6 | 7 | docker-compose run --rm dev "${@-sh}" 8 | -------------------------------------------------------------------------------- /bin/.gtikeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yob/graval/ea9d2ebc6462d8418ec62d13fca7c5f16dc4386e/bin/.gtikeep -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jehiah/go-strftime" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type ftpCommand interface { 12 | RequireParam() bool 13 | RequireAuth() bool 14 | Execute(*ftpConn, string) 15 | } 16 | 17 | type commandMap map[string]ftpCommand 18 | 19 | var ( 20 | commands = commandMap{ 21 | "ALLO": commandAllo{}, 22 | "CDUP": commandCdup{}, 23 | "CWD": commandCwd{}, 24 | "DELE": commandDele{}, 25 | "EPRT": commandEprt{}, 26 | "EPSV": commandEpsv{}, 27 | "FEAT": commandFeat{}, 28 | "LIST": commandList{}, 29 | "NLST": commandNlst{}, 30 | "MDTM": commandMdtm{}, 31 | "MKD": commandMkd{}, 32 | "MODE": commandMode{}, 33 | "NOOP": commandNoop{}, 34 | "OPTS": commandOpts{}, 35 | "PASS": commandPass{}, 36 | "PASV": commandPasv{}, 37 | "PORT": commandPort{}, 38 | "PWD": commandPwd{}, 39 | "QUIT": commandQuit{}, 40 | "RETR": commandRetr{}, 41 | "RNFR": commandRnfr{}, 42 | "RNTO": commandRnto{}, 43 | "RMD": commandRmd{}, 44 | "SIZE": commandSize{}, 45 | "STOR": commandStor{}, 46 | "STRU": commandStru{}, 47 | "SYST": commandSyst{}, 48 | "TYPE": commandType{}, 49 | "USER": commandUser{}, 50 | "XCUP": commandCdup{}, 51 | "XCWD": commandCwd{}, 52 | "XPWD": commandPwd{}, 53 | "XRMD": commandRmd{}, 54 | } 55 | 56 | // Some FTP clients send flags to the LIST and NLST commands. Server support for these varies, 57 | // and implementing them all would be a lot of work with uncertain payoff. For now, we ignore them 58 | listFlagsRegexp = `^-[alt]+$` 59 | ) 60 | 61 | // commandAllo responds to the ALLO FTP command. 62 | // 63 | // This is essentially a ping from the client so we just respond with an 64 | // basic OK message. 65 | type commandAllo struct{} 66 | 67 | func (cmd commandAllo) RequireParam() bool { 68 | return false 69 | } 70 | 71 | func (cmd commandAllo) RequireAuth() bool { 72 | return false 73 | } 74 | 75 | func (cmd commandAllo) Execute(conn *ftpConn, param string) { 76 | conn.writeMessage(202, "Obsolete") 77 | } 78 | 79 | // commandCdup responds to the CDUP FTP command. 80 | // 81 | // Allows the client change their current directory to the parent. 82 | type commandCdup struct{} 83 | 84 | func (cmd commandCdup) RequireParam() bool { 85 | return false 86 | } 87 | 88 | func (cmd commandCdup) RequireAuth() bool { 89 | return true 90 | } 91 | 92 | func (cmd commandCdup) Execute(conn *ftpConn, param string) { 93 | otherCmd := &commandCwd{} 94 | otherCmd.Execute(conn, "..") 95 | } 96 | 97 | // commandCwd responds to the CWD FTP command. It allows the client to change the 98 | // current working directory. 99 | type commandCwd struct{} 100 | 101 | func (cmd commandCwd) RequireParam() bool { 102 | return true 103 | } 104 | 105 | func (cmd commandCwd) RequireAuth() bool { 106 | return true 107 | } 108 | 109 | func (cmd commandCwd) Execute(conn *ftpConn, param string) { 110 | path := conn.buildPath(param) 111 | if conn.driver.ChangeDir(path) { 112 | conn.namePrefix = path 113 | conn.writeMessage(250, "Directory changed to "+path) 114 | } else { 115 | conn.writeMessage(550, "Action not taken") 116 | } 117 | } 118 | 119 | // commandDele responds to the DELE FTP command. It allows the client to delete 120 | // a file 121 | type commandDele struct{} 122 | 123 | func (cmd commandDele) RequireParam() bool { 124 | return true 125 | } 126 | 127 | func (cmd commandDele) RequireAuth() bool { 128 | return true 129 | } 130 | 131 | func (cmd commandDele) Execute(conn *ftpConn, param string) { 132 | path := conn.buildPath(param) 133 | if conn.driver.DeleteFile(path) { 134 | conn.writeMessage(250, "File deleted") 135 | } else { 136 | conn.writeMessage(550, "Action not taken") 137 | } 138 | } 139 | 140 | // commandEprt responds to the EPRT FTP command. It allows the client to 141 | // request an active data socket with more options than the original PORT 142 | // command. It mainly adds ipv6 support. 143 | type commandEprt struct{} 144 | 145 | func (cmd commandEprt) RequireParam() bool { 146 | return true 147 | } 148 | 149 | func (cmd commandEprt) RequireAuth() bool { 150 | return true 151 | } 152 | 153 | func (cmd commandEprt) Execute(conn *ftpConn, param string) { 154 | delim := string(param[0:1]) 155 | parts := strings.Split(param, delim) 156 | addressFamily, err := strconv.Atoi(parts[1]) 157 | host := parts[2] 158 | port, err := strconv.Atoi(parts[3]) 159 | 160 | if addressFamily != 1 && addressFamily != 2 { 161 | conn.writeMessage(522, "Network protocol not supported, use (1,2)") 162 | return 163 | } 164 | 165 | _, err = conn.newActiveSocket(host, port) 166 | 167 | if err != nil { 168 | conn.writeMessage(425, "Data connection failed") 169 | return 170 | } 171 | conn.writeMessage(200, fmt.Sprintf("Connection established (%d)", port)) 172 | } 173 | 174 | // commandEpsv responds to the EPSV FTP command. It allows the client to 175 | // request a passive data socket with more options than the original PASV 176 | // command. It mainly adds ipv6 support, although we don't support that yet. 177 | type commandEpsv struct{} 178 | 179 | func (cmd commandEpsv) RequireParam() bool { 180 | return false 181 | } 182 | 183 | func (cmd commandEpsv) RequireAuth() bool { 184 | return true 185 | } 186 | 187 | func (cmd commandEpsv) Execute(conn *ftpConn, param string) { 188 | socket, err := conn.newPassiveSocket() 189 | if err != nil { 190 | conn.writeMessage(425, "Data connection failed") 191 | return 192 | } 193 | msg := fmt.Sprintf("Entering Extended Passive Mode (|||%d|)", socket.Port()) 194 | conn.writeMessage(229, msg) 195 | } 196 | 197 | // commandFeat responds to the FEAT FTP command. 198 | // 199 | // List all new features supported as defined in RFC-2398. 200 | type commandFeat struct{} 201 | 202 | func (cmd commandFeat) RequireParam() bool { 203 | return false 204 | } 205 | 206 | func (cmd commandFeat) RequireAuth() bool { 207 | return false 208 | } 209 | 210 | func (cmd commandFeat) Execute(conn *ftpConn, param string) { 211 | conn.writeLines(211, 212 | "211-Features supported:", 213 | " EPRT", 214 | " EPSV", 215 | " MDTM", 216 | " SIZE", 217 | " UTF8", 218 | "211 End FEAT.", 219 | ) 220 | } 221 | 222 | // commandList responds to the LIST FTP command. It allows the client to retreive 223 | // a detailed listing of the contents of a directory. 224 | type commandList struct{} 225 | 226 | func (cmd commandList) RequireParam() bool { 227 | return false 228 | } 229 | 230 | func (cmd commandList) RequireAuth() bool { 231 | return true 232 | } 233 | 234 | func (cmd commandList) Execute(conn *ftpConn, param string) { 235 | conn.writeMessage(150, "Opening ASCII mode data connection for file list") 236 | matched, _ := regexp.MatchString(listFlagsRegexp, param) 237 | if matched { 238 | param = "" 239 | } 240 | path := conn.buildPath(param) 241 | files := conn.driver.DirContents(path) 242 | formatter := newListFormatter(files) 243 | conn.sendOutofbandData(formatter.Detailed()) 244 | } 245 | 246 | // commandNlst responds to the NLST FTP command. It allows the client to 247 | // retreive a list of filenames in the current directory. 248 | type commandNlst struct{} 249 | 250 | func (cmd commandNlst) RequireParam() bool { 251 | return false 252 | } 253 | 254 | func (cmd commandNlst) RequireAuth() bool { 255 | return true 256 | } 257 | 258 | func (cmd commandNlst) Execute(conn *ftpConn, param string) { 259 | conn.writeMessage(150, "Opening ASCII mode data connection for file list") 260 | matched, _ := regexp.MatchString(listFlagsRegexp, param) 261 | if matched { 262 | param = "" 263 | } 264 | path := conn.buildPath(param) 265 | files := conn.driver.DirContents(path) 266 | formatter := newListFormatter(files) 267 | conn.sendOutofbandData(formatter.Short()) 268 | } 269 | 270 | // commandMdtm responds to the MDTM FTP command. It allows the client to 271 | // retreive the last modified time of a file. 272 | type commandMdtm struct{} 273 | 274 | func (cmd commandMdtm) RequireParam() bool { 275 | return true 276 | } 277 | 278 | func (cmd commandMdtm) RequireAuth() bool { 279 | return true 280 | } 281 | 282 | func (cmd commandMdtm) Execute(conn *ftpConn, param string) { 283 | path := conn.buildPath(param) 284 | time, err := conn.driver.ModifiedTime(path) 285 | if err == nil { 286 | conn.writeMessage(213, strftime.Format("%Y%m%d%H%M%S", time)) 287 | } else { 288 | conn.writeMessage(450, "File not available") 289 | } 290 | } 291 | 292 | // commandMkd responds to the MKD FTP command. It allows the client to create 293 | // a new directory 294 | type commandMkd struct{} 295 | 296 | func (cmd commandMkd) RequireParam() bool { 297 | return true 298 | } 299 | 300 | func (cmd commandMkd) RequireAuth() bool { 301 | return true 302 | } 303 | 304 | func (cmd commandMkd) Execute(conn *ftpConn, param string) { 305 | path := conn.buildPath(param) 306 | if conn.driver.MakeDir(path) { 307 | conn.writeMessage(257, "Directory created") 308 | } else { 309 | conn.writeMessage(550, "Action not taken") 310 | } 311 | } 312 | 313 | // commandMode responds to the MODE FTP command. 314 | // 315 | // the original FTP spec had various options for hosts to negotiate how data 316 | // would be sent over the data socket, In reality these days (S)tream mode 317 | // is all that is used for the mode - data is just streamed down the data 318 | // socket unchanged. 319 | type commandMode struct{} 320 | 321 | func (cmd commandMode) RequireParam() bool { 322 | return true 323 | } 324 | 325 | func (cmd commandMode) RequireAuth() bool { 326 | return true 327 | } 328 | 329 | func (cmd commandMode) Execute(conn *ftpConn, param string) { 330 | if strings.ToUpper(param) == "S" { 331 | conn.writeMessage(200, "OK") 332 | } else { 333 | conn.writeMessage(504, "MODE is an obsolete command") 334 | } 335 | } 336 | 337 | // commandNoop responds to the NOOP FTP command. 338 | // 339 | // This is essentially a ping from the client so we just respond with an 340 | // basic 200 message. 341 | type commandNoop struct{} 342 | 343 | func (cmd commandNoop) RequireParam() bool { 344 | return false 345 | } 346 | 347 | func (cmd commandNoop) RequireAuth() bool { 348 | return false 349 | } 350 | 351 | func (cmd commandNoop) Execute(conn *ftpConn, param string) { 352 | conn.writeMessage(200, "OK") 353 | } 354 | 355 | // commandOpts responds to the OPTS FTP command. 356 | // 357 | // This is essentially a ping from the client so we just respond with an 358 | // basic 200 message. 359 | type commandOpts struct{} 360 | 361 | func (cmd commandOpts) RequireParam() bool { 362 | return false 363 | } 364 | 365 | func (cmd commandOpts) RequireAuth() bool { 366 | return true 367 | } 368 | 369 | func (cmd commandOpts) Execute(conn *ftpConn, param string) { 370 | if param == "UTF8 ON" || param == "UTF8" { 371 | conn.writeMessage(200, "OK") 372 | return 373 | } 374 | 375 | conn.writeMessage(500, "Command not found") 376 | } 377 | 378 | // commandPass respond to the PASS FTP command by asking the driver if the 379 | // supplied username and password are valid 380 | type commandPass struct{} 381 | 382 | func (cmd commandPass) RequireParam() bool { 383 | return true 384 | } 385 | 386 | func (cmd commandPass) RequireAuth() bool { 387 | return false 388 | } 389 | 390 | func (cmd commandPass) Execute(conn *ftpConn, param string) { 391 | if conn.driver.Authenticate(conn.reqUser, param) { 392 | conn.user = conn.reqUser 393 | conn.reqUser = "" 394 | conn.writeMessage(230, "Password ok, continue") 395 | } else { 396 | conn.writeMessage(530, "Incorrect password, not logged in") 397 | conn.writeMessage(221, "Goodbye.") 398 | conn.Close() 399 | } 400 | } 401 | 402 | // commandPasv responds to the PASV FTP command. 403 | // 404 | // The client is requesting us to open a new TCP listing socket and wait for them 405 | // to connect to it. 406 | type commandPasv struct{} 407 | 408 | func (cmd commandPasv) RequireParam() bool { 409 | return false 410 | } 411 | 412 | func (cmd commandPasv) RequireAuth() bool { 413 | return true 414 | } 415 | 416 | func (cmd commandPasv) Execute(conn *ftpConn, param string) { 417 | socket, err := conn.newPassiveSocket() 418 | if err != nil { 419 | conn.writeMessage(425, "Data connection failed") 420 | return 421 | } 422 | 423 | p1 := socket.Port() / 256 424 | p2 := socket.Port() - (p1 * 256) 425 | 426 | // if the server has been configured to send a specific IP for clients to connect to, use it. Otherwise 427 | // fallback to the IP that the passive port is listening on 428 | host := conn.pasvAdvertisedIp 429 | if host == "" { 430 | host = socket.Host() 431 | } 432 | quads := strings.Split(host, ".") 433 | target := fmt.Sprintf("(%s,%s,%s,%s,%d,%d)", quads[0], quads[1], quads[2], quads[3], p1, p2) 434 | msg := "Entering Passive Mode " + target 435 | conn.writeMessage(227, msg) 436 | } 437 | 438 | // commandPort responds to the PORT FTP command. 439 | // 440 | // The client has opened a listening socket for sending out of band data and 441 | // is requesting that we connect to it 442 | type commandPort struct{} 443 | 444 | func (cmd commandPort) RequireParam() bool { 445 | return true 446 | } 447 | 448 | func (cmd commandPort) RequireAuth() bool { 449 | return true 450 | } 451 | 452 | func (cmd commandPort) Execute(conn *ftpConn, param string) { 453 | nums := strings.Split(param, ",") 454 | portOne, _ := strconv.Atoi(nums[4]) 455 | portTwo, _ := strconv.Atoi(nums[5]) 456 | port := (portOne * 256) + portTwo 457 | host := nums[0] + "." + nums[1] + "." + nums[2] + "." + nums[3] 458 | 459 | _, err := conn.newActiveSocket(host, port) 460 | 461 | if err != nil { 462 | conn.writeMessage(425, "Data connection failed") 463 | return 464 | } 465 | conn.writeMessage(200, fmt.Sprintf("Connection established (%d)", port)) 466 | } 467 | 468 | // commandPwd responds to the PWD FTP command. 469 | // 470 | // Tells the client what the current working directory is. 471 | type commandPwd struct{} 472 | 473 | func (cmd commandPwd) RequireParam() bool { 474 | return false 475 | } 476 | 477 | func (cmd commandPwd) RequireAuth() bool { 478 | return true 479 | } 480 | 481 | func (cmd commandPwd) Execute(conn *ftpConn, param string) { 482 | conn.writeMessage(257, "\""+conn.namePrefix+"\" is the current directory") 483 | } 484 | 485 | // CommandQuit responds to the QUIT FTP command. The client has requested the 486 | // connection be closed. 487 | type commandQuit struct{} 488 | 489 | func (cmd commandQuit) RequireParam() bool { 490 | return false 491 | } 492 | 493 | func (cmd commandQuit) RequireAuth() bool { 494 | return false 495 | } 496 | 497 | func (cmd commandQuit) Execute(conn *ftpConn, param string) { 498 | conn.Close() 499 | } 500 | 501 | // commandRetr responds to the RETR FTP command. It allows the client to 502 | // download a file. 503 | type commandRetr struct{} 504 | 505 | func (cmd commandRetr) RequireParam() bool { 506 | return true 507 | } 508 | 509 | func (cmd commandRetr) RequireAuth() bool { 510 | return true 511 | } 512 | 513 | func (cmd commandRetr) Execute(conn *ftpConn, param string) { 514 | path := conn.buildPath(param) 515 | reader, err := conn.driver.GetFile(path) 516 | if err == nil { 517 | defer reader.Close() 518 | conn.writeMessage(150, "Data connection open. Transfer starting.") 519 | conn.sendOutofbandReader(reader) 520 | } else { 521 | conn.writeMessage(551, "File not available") 522 | } 523 | } 524 | 525 | // commandRnfr responds to the RNFR FTP command. It's the first of two commands 526 | // required for a client to rename a file. 527 | type commandRnfr struct{} 528 | 529 | func (cmd commandRnfr) RequireParam() bool { 530 | return true 531 | } 532 | 533 | func (cmd commandRnfr) RequireAuth() bool { 534 | return true 535 | } 536 | 537 | func (cmd commandRnfr) Execute(conn *ftpConn, param string) { 538 | conn.renameFrom = conn.buildPath(param) 539 | conn.writeMessage(350, "Requested file action pending further information.") 540 | } 541 | 542 | // commandRnto responds to the RNTO FTP command. It's the second of two commands 543 | // required for a client to rename a file. 544 | type commandRnto struct{} 545 | 546 | func (cmd commandRnto) RequireParam() bool { 547 | return true 548 | } 549 | 550 | func (cmd commandRnto) RequireAuth() bool { 551 | return true 552 | } 553 | 554 | func (cmd commandRnto) Execute(conn *ftpConn, param string) { 555 | if conn.renameFrom == "" { 556 | conn.writeMessage(503, "Bad sequence of commands: use RNFR first.") 557 | return 558 | } 559 | 560 | toPath := conn.buildPath(param) 561 | if conn.driver.Rename(conn.renameFrom, toPath) { 562 | conn.writeMessage(250, "File renamed") 563 | } else { 564 | conn.writeMessage(550, "Action not taken") 565 | } 566 | } 567 | 568 | // commandRmd responds to the RMD FTP command. It allows the client to delete a 569 | // directory. 570 | type commandRmd struct{} 571 | 572 | func (cmd commandRmd) RequireParam() bool { 573 | return true 574 | } 575 | 576 | func (cmd commandRmd) RequireAuth() bool { 577 | return true 578 | } 579 | 580 | func (cmd commandRmd) Execute(conn *ftpConn, param string) { 581 | path := conn.buildPath(param) 582 | if conn.driver.DeleteDir(path) { 583 | conn.writeMessage(250, "Directory deleted") 584 | } else { 585 | conn.writeMessage(550, "Action not taken") 586 | } 587 | } 588 | 589 | // commandSize responds to the SIZE FTP command. It returns the size of the 590 | // requested path in bytes. 591 | type commandSize struct{} 592 | 593 | func (cmd commandSize) RequireParam() bool { 594 | return true 595 | } 596 | 597 | func (cmd commandSize) RequireAuth() bool { 598 | return true 599 | } 600 | 601 | func (cmd commandSize) Execute(conn *ftpConn, param string) { 602 | path := conn.buildPath(param) 603 | bytes := conn.driver.Bytes(path) 604 | if bytes >= 0 { 605 | conn.writeMessage(213, fmt.Sprintf("%d", bytes)) 606 | } else { 607 | conn.writeMessage(450, "file not available") 608 | } 609 | } 610 | 611 | // commandStor responds to the STOR FTP command. It allows the user to upload a 612 | // new file. 613 | type commandStor struct{} 614 | 615 | func (cmd commandStor) RequireParam() bool { 616 | return true 617 | } 618 | 619 | func (cmd commandStor) RequireAuth() bool { 620 | return true 621 | } 622 | 623 | func (cmd commandStor) Execute(conn *ftpConn, param string) { 624 | targetPath := conn.buildPath(param) 625 | conn.writeMessage(150, "Data transfer starting") 626 | if ok := conn.driver.PutFile(targetPath, conn.dataConn); ok { 627 | conn.writeMessage(226, "Transfer complete.") 628 | } else { 629 | conn.writeMessage(450, "error during transfer") 630 | } 631 | } 632 | 633 | // commandStru responds to the STRU FTP command. 634 | // 635 | // like the MODE and TYPE commands, stru[cture] dates back to a time when the 636 | // FTP protocol was more aware of the content of the files it was transferring, 637 | // and would sometimes be expected to translate things like EOL markers on the 638 | // fly. 639 | // 640 | // These days files are sent unmodified, and F(ile) mode is the only one we 641 | // really need to support. 642 | type commandStru struct{} 643 | 644 | func (cmd commandStru) RequireParam() bool { 645 | return true 646 | } 647 | 648 | func (cmd commandStru) RequireAuth() bool { 649 | return true 650 | } 651 | 652 | func (cmd commandStru) Execute(conn *ftpConn, param string) { 653 | if strings.ToUpper(param) == "F" { 654 | conn.writeMessage(200, "OK") 655 | } else { 656 | conn.writeMessage(504, "STRU is an obsolete command") 657 | } 658 | } 659 | 660 | // commandSyst responds to the SYST FTP command by providing a canned response. 661 | type commandSyst struct{} 662 | 663 | func (cmd commandSyst) RequireParam() bool { 664 | return false 665 | } 666 | 667 | func (cmd commandSyst) RequireAuth() bool { 668 | return true 669 | } 670 | 671 | func (cmd commandSyst) Execute(conn *ftpConn, param string) { 672 | conn.writeMessage(215, "UNIX Type: L8") 673 | } 674 | 675 | // commandType responds to the TYPE FTP command. 676 | // 677 | // like the MODE and STRU commands, TYPE dates back to a time when the FTP 678 | // protocol was more aware of the content of the files it was transferring, and 679 | // would sometimes be expected to translate things like EOL markers on the fly. 680 | // 681 | // Valid options were A(SCII), I(mage), E(BCDIC) or LN (for local type). Since 682 | // we plan to just accept bytes from the client unchanged, I think Image mode is 683 | // adequate. The RFC requires we accept ASCII mode however, so accept it, but 684 | // ignore it. 685 | type commandType struct{} 686 | 687 | func (cmd commandType) RequireParam() bool { 688 | return false 689 | } 690 | 691 | func (cmd commandType) RequireAuth() bool { 692 | return true 693 | } 694 | 695 | func (cmd commandType) Execute(conn *ftpConn, param string) { 696 | if strings.ToUpper(param) == "A" { 697 | conn.writeMessage(200, "Type set to ASCII") 698 | } else if strings.ToUpper(param) == "I" { 699 | conn.writeMessage(200, "Type set to binary") 700 | } else { 701 | conn.writeMessage(500, "Invalid type") 702 | } 703 | } 704 | 705 | // commandUser responds to the USER FTP command by asking for the password 706 | type commandUser struct{} 707 | 708 | func (cmd commandUser) RequireParam() bool { 709 | return true 710 | } 711 | 712 | func (cmd commandUser) RequireAuth() bool { 713 | return false 714 | } 715 | 716 | func (cmd commandUser) Execute(conn *ftpConn, param string) { 717 | conn.reqUser = param 718 | conn.writeMessage(331, "User name ok, password required") 719 | } 720 | -------------------------------------------------------------------------------- /commands_test.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | . "github.com/smartystreets/goconvey/convey" 5 | "testing" 6 | ) 7 | 8 | func TestStringMapsToCorrectCommands(t *testing.T) { 9 | Convey("Command map calls correct objects", t, func() { 10 | So(commands["ALLO"], ShouldHaveSameTypeAs, commandAllo{}) 11 | So(commands["CDUP"], ShouldHaveSameTypeAs, commandCdup{}) 12 | So(commands["CWD"], ShouldHaveSameTypeAs, commandCwd{}) 13 | So(commands["DELE"], ShouldHaveSameTypeAs, commandDele{}) 14 | So(commands["EPRT"], ShouldHaveSameTypeAs, commandEprt{}) 15 | So(commands["EPSV"], ShouldHaveSameTypeAs, commandEpsv{}) 16 | So(commands["LIST"], ShouldHaveSameTypeAs, commandList{}) 17 | So(commands["NLST"], ShouldHaveSameTypeAs, commandNlst{}) 18 | So(commands["MDTM"], ShouldHaveSameTypeAs, commandMdtm{}) 19 | So(commands["MKD"], ShouldHaveSameTypeAs, commandMkd{}) 20 | So(commands["MODE"], ShouldHaveSameTypeAs, commandMode{}) 21 | So(commands["NOOP"], ShouldHaveSameTypeAs, commandNoop{}) 22 | So(commands["PASS"], ShouldHaveSameTypeAs, commandPass{}) 23 | So(commands["PASV"], ShouldHaveSameTypeAs, commandPasv{}) 24 | So(commands["PORT"], ShouldHaveSameTypeAs, commandPort{}) 25 | So(commands["PWD"], ShouldHaveSameTypeAs, commandPwd{}) 26 | So(commands["QUIT"], ShouldHaveSameTypeAs, commandQuit{}) 27 | So(commands["RETR"], ShouldHaveSameTypeAs, commandRetr{}) 28 | So(commands["RNFR"], ShouldHaveSameTypeAs, commandRnfr{}) 29 | So(commands["RNTO"], ShouldHaveSameTypeAs, commandRnto{}) 30 | So(commands["RMD"], ShouldHaveSameTypeAs, commandRmd{}) 31 | So(commands["SIZE"], ShouldHaveSameTypeAs, commandSize{}) 32 | So(commands["STOR"], ShouldHaveSameTypeAs, commandStor{}) 33 | So(commands["STRU"], ShouldHaveSameTypeAs, commandStru{}) 34 | So(commands["SYST"], ShouldHaveSameTypeAs, commandSyst{}) 35 | So(commands["TYPE"], ShouldHaveSameTypeAs, commandType{}) 36 | So(commands["USER"], ShouldHaveSameTypeAs, commandUser{}) 37 | So(commands["XCUP"], ShouldHaveSameTypeAs, commandCdup{}) 38 | So(commands["XCWD"], ShouldHaveSameTypeAs, commandCwd{}) 39 | So(commands["XPWD"], ShouldHaveSameTypeAs, commandPwd{}) 40 | So(commands["XRMD"], ShouldHaveSameTypeAs, commandRmd{}) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | services: 4 | dev: 5 | image: golang:1.15.2-buster 6 | volumes: 7 | - .:/work 8 | - graval-mod:/go/pkg/mod/ 9 | working_dir: /work 10 | command: bash 11 | environment: 12 | GO111MODULE: "on" 13 | 14 | volumes: 15 | graval-mod: ~ 16 | -------------------------------------------------------------------------------- /ftpconn.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "encoding/hex" 9 | "fmt" 10 | "io" 11 | "net" 12 | "path/filepath" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | type ftpConn struct { 18 | conn net.Conn 19 | controlReader *bufio.Reader 20 | controlWriter *bufio.Writer 21 | dataConn ftpDataSocket 22 | driver FTPDriver 23 | logger *ftpLogger 24 | serverName string 25 | sessionId string 26 | namePrefix string 27 | reqUser string 28 | user string 29 | renameFrom string 30 | minDataPort int 31 | maxDataPort int 32 | pasvAdvertisedIp string 33 | } 34 | 35 | // NewftpConn constructs a new object that will handle the FTP protocol over 36 | // an active net.TCPConn. The TCP connection should already be open before 37 | // it is handed to this functions. driver is an instance of FTPDriver that 38 | // will handle all auth and persistence details. 39 | func newftpConn(tcpConn net.Conn, driver FTPDriver, serverName string, minPort int, maxPort int, pasvAdvertisedIp string) *ftpConn { 40 | c := new(ftpConn) 41 | c.namePrefix = "/" 42 | c.conn = tcpConn 43 | c.controlReader = bufio.NewReader(tcpConn) 44 | c.controlWriter = bufio.NewWriter(tcpConn) 45 | c.driver = driver 46 | c.sessionId = newSessionId() 47 | c.logger = newFtpLogger(c.sessionId) 48 | c.serverName = serverName 49 | c.minDataPort = minPort 50 | c.maxDataPort = maxPort 51 | c.pasvAdvertisedIp = pasvAdvertisedIp 52 | return c 53 | } 54 | 55 | // returns a random 20 char string that can be used as a unique session ID 56 | func newSessionId() string { 57 | hash := sha256.New() 58 | _, err := io.CopyN(hash, rand.Reader, 50) 59 | if err != nil { 60 | return "????????????????????" 61 | } 62 | md := hash.Sum(nil) 63 | mdStr := hex.EncodeToString(md) 64 | return mdStr[0:20] 65 | } 66 | 67 | // Serve starts an endless loop that reads FTP commands from the client and 68 | // responds appropriately. terminated is a channel that will receive a true 69 | // message when the connection closes. This loop will be running inside a 70 | // goroutine, so use this channel to be notified when the connection can be 71 | // cleaned up. 72 | func (ftpConn *ftpConn) Serve() { 73 | defer func() { 74 | if r := recover(); r != nil { 75 | ftpConn.logger.Printf("Recovered in ftpConn Serve: %s", r) 76 | } 77 | 78 | ftpConn.Close() 79 | }() 80 | 81 | ftpConn.logger.Printf("Connection Established (local: %s, remote: %s)", ftpConn.localIP(), ftpConn.remoteIP()) 82 | // send welcome 83 | ftpConn.writeMessage(220, ftpConn.serverName) 84 | // read commands 85 | for { 86 | line, err := ftpConn.controlReader.ReadString('\n') 87 | if err != nil { 88 | break 89 | } 90 | ftpConn.receiveLine(line) 91 | } 92 | ftpConn.logger.Print("Connection Terminated") 93 | } 94 | 95 | // Close will manually close this connection, even if the client isn't ready. 96 | func (ftpConn *ftpConn) Close() { 97 | ftpConn.conn.Close() 98 | if ftpConn.dataConn != nil { 99 | ftpConn.dataConn.Close() 100 | } 101 | } 102 | 103 | // receiveLine accepts a single line FTP command and co-ordinates an 104 | // appropriate response. 105 | func (ftpConn *ftpConn) receiveLine(line string) { 106 | command, param := ftpConn.parseLine(line) 107 | ftpConn.logger.PrintCommand(command, param) 108 | cmdObj := commands[command] 109 | if cmdObj == nil { 110 | ftpConn.writeMessage(500, "Command not found") 111 | return 112 | } 113 | if cmdObj.RequireParam() && param == "" { 114 | ftpConn.writeMessage(553, "action aborted, required param missing") 115 | } else if cmdObj.RequireAuth() && ftpConn.user == "" { 116 | ftpConn.writeMessage(530, "not logged in") 117 | } else { 118 | cmdObj.Execute(ftpConn, param) 119 | } 120 | } 121 | 122 | func (ftpConn *ftpConn) parseLine(line string) (string, string) { 123 | params := strings.SplitN(strings.Trim(line, "\r\n"), " ", 2) 124 | if len(params) == 1 { 125 | return params[0], "" 126 | } 127 | return params[0], strings.TrimSpace(params[1]) 128 | } 129 | 130 | // writeMessage will send a standard FTP response back to the client. 131 | func (ftpConn *ftpConn) writeMessage(code int, message string) (wrote int, err error) { 132 | ftpConn.logger.PrintResponse(code, message) 133 | line := fmt.Sprintf("%d %s\r\n", code, message) 134 | wrote, err = ftpConn.controlWriter.WriteString(line) 135 | ftpConn.controlWriter.Flush() 136 | return 137 | } 138 | 139 | // writeLines will send a multiline FTP response back to the client. 140 | func (ftpConn *ftpConn) writeLines(code int, lines ...string) (wrote int, err error) { 141 | message := strings.Join(lines, "\r\n") + "\r\n" 142 | ftpConn.logger.PrintResponse(code, message) 143 | wrote, err = ftpConn.controlWriter.WriteString(message) 144 | ftpConn.controlWriter.Flush() 145 | return 146 | } 147 | 148 | // buildPath takes a client supplied path or filename and generates a safe 149 | // absolute path within their account sandbox. 150 | // 151 | // buildpath("/") 152 | // => "/" 153 | // buildpath("one.txt") 154 | // => "/one.txt" 155 | // buildpath("/files/two.txt") 156 | // => "/files/two.txt" 157 | // buildpath("files/two.txt") 158 | // => "files/two.txt" 159 | // buildpath("/../../../../etc/passwd") 160 | // => "/etc/passwd" 161 | // 162 | // The driver implementation is responsible for deciding how to treat this path. 163 | // Obviously they MUST NOT just read the path off disk. The probably want to 164 | // prefix the path with something to scope the users access to a sandbox. 165 | func (ftpConn *ftpConn) buildPath(filename string) (fullPath string) { 166 | if len(filename) > 0 && filename[0:1] == "/" { 167 | fullPath = filepath.Clean(filename) 168 | } else if len(filename) > 0 { 169 | fullPath = filepath.Clean(ftpConn.namePrefix + "/" + filename) 170 | } else { 171 | fullPath = filepath.Clean(ftpConn.namePrefix) 172 | } 173 | fullPath = strings.Replace(fullPath, "//", "/", -1) 174 | return 175 | } 176 | 177 | // the server IP that is being used for this connection. May be the same for all connections, 178 | // or may vary if the server is listening on 0.0.0.0 179 | func (ftpConn *ftpConn) localIP() string { 180 | lAddr := ftpConn.conn.LocalAddr().(*net.TCPAddr) 181 | return lAddr.IP.String() 182 | } 183 | 184 | // the client IP address 185 | func (ftpConn *ftpConn) remoteIP() string { 186 | rAddr := ftpConn.conn.RemoteAddr().(*net.TCPAddr) 187 | return rAddr.IP.String() 188 | } 189 | 190 | // sendOutofbandData will copy data from reader to the client via the currently 191 | // open data socket. Assumes the socket is open and ready to be used. 192 | func (ftpConn *ftpConn) sendOutofbandReader(reader io.Reader) { 193 | defer ftpConn.dataConn.Close() 194 | 195 | _, err := io.Copy(ftpConn.dataConn, reader) 196 | 197 | if err != nil { 198 | ftpConn.logger.Printf("sendOutofbandReader copy error %s", err) 199 | ftpConn.writeMessage(550, "Action not taken") 200 | return 201 | } 202 | 203 | ftpConn.writeMessage(226, "Transfer complete.") 204 | 205 | // Chrome dies on localhost if we close connection to soon 206 | time.Sleep(10 * time.Millisecond) 207 | } 208 | 209 | // sendOutofbandData will send a string to the client via the currently open 210 | // data socket. Assumes the socket is open and ready to be used. 211 | func (ftpConn *ftpConn) sendOutofbandData(data string) { 212 | ftpConn.sendOutofbandReader(bytes.NewReader([]byte(data))) 213 | } 214 | 215 | func (ftpConn *ftpConn) newPassiveSocket() (socket *ftpPassiveSocket, err error) { 216 | if ftpConn.dataConn != nil { 217 | ftpConn.dataConn.Close() 218 | ftpConn.dataConn = nil 219 | } 220 | 221 | socket, err = newPassiveSocket(ftpConn.localIP(), ftpConn.minDataPort, ftpConn.maxDataPort, ftpConn.logger) 222 | 223 | if err == nil { 224 | ftpConn.dataConn = socket 225 | } 226 | 227 | return 228 | } 229 | 230 | func (ftpConn *ftpConn) newActiveSocket(host string, port int) (socket *ftpActiveSocket, err error) { 231 | if ftpConn.dataConn != nil { 232 | ftpConn.dataConn.Close() 233 | ftpConn.dataConn = nil 234 | } 235 | 236 | socket, err = newActiveSocket(host, port, ftpConn.logger) 237 | 238 | if err == nil { 239 | ftpConn.dataConn = socket 240 | } 241 | 242 | return 243 | } 244 | -------------------------------------------------------------------------------- /ftpdatasocket.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | "errors" 5 | "math/rand" 6 | "net" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | // A data socket is used to send non-control data between the client and 12 | // server. 13 | type ftpDataSocket interface { 14 | Host() string 15 | 16 | Port() int 17 | 18 | // the standard io.Reader interface 19 | Read(p []byte) (n int, err error) 20 | 21 | // the standard io.Writer interface 22 | Write(p []byte) (n int, err error) 23 | 24 | // the standard io.Closer interface 25 | Close() error 26 | } 27 | 28 | type ftpActiveSocket struct { 29 | conn *net.TCPConn 30 | host string 31 | port int 32 | logger *ftpLogger 33 | } 34 | 35 | func newActiveSocket(host string, port int, logger *ftpLogger) (*ftpActiveSocket, error) { 36 | connectTo := buildTcpString(host, port) 37 | logger.Print("Opening active data connection to " + connectTo) 38 | raddr, err := net.ResolveTCPAddr("tcp", connectTo) 39 | if err != nil { 40 | logger.Print(err) 41 | return nil, err 42 | } 43 | tcpConn, err := net.DialTCP("tcp", nil, raddr) 44 | if err != nil { 45 | logger.Print(err) 46 | return nil, err 47 | } 48 | socket := new(ftpActiveSocket) 49 | socket.conn = tcpConn 50 | socket.host = host 51 | socket.port = port 52 | socket.logger = logger 53 | return socket, nil 54 | } 55 | 56 | func (socket *ftpActiveSocket) Host() string { 57 | return socket.host 58 | } 59 | 60 | func (socket *ftpActiveSocket) Port() int { 61 | return socket.port 62 | } 63 | 64 | func (socket *ftpActiveSocket) Read(p []byte) (n int, err error) { 65 | return socket.conn.Read(p) 66 | } 67 | 68 | func (socket *ftpActiveSocket) Write(p []byte) (n int, err error) { 69 | return socket.conn.Write(p) 70 | } 71 | 72 | func (socket *ftpActiveSocket) Close() error { 73 | return socket.conn.Close() 74 | } 75 | 76 | type ftpPassiveSocket struct { 77 | conn *net.TCPConn 78 | port int 79 | listenIP string 80 | logger *ftpLogger 81 | } 82 | 83 | func newPassiveSocket(listenIP string, minPort int, maxPort int, logger *ftpLogger) (*ftpPassiveSocket, error) { 84 | socket := new(ftpPassiveSocket) 85 | socket.logger = logger 86 | socket.listenIP = listenIP 87 | go socket.ListenAndServe(minPort, maxPort) 88 | for { 89 | if socket.Port() > 0 { 90 | break 91 | } 92 | time.Sleep(100 * time.Millisecond) 93 | } 94 | return socket, nil 95 | } 96 | 97 | func (socket *ftpPassiveSocket) Host() string { 98 | return socket.listenIP 99 | } 100 | 101 | func (socket *ftpPassiveSocket) Port() int { 102 | return socket.port 103 | } 104 | 105 | func (socket *ftpPassiveSocket) Read(p []byte) (n int, err error) { 106 | if socket.waitForOpenSocket() == false { 107 | return 0, errors.New("data socket unavailable") 108 | } 109 | return socket.conn.Read(p) 110 | } 111 | 112 | func (socket *ftpPassiveSocket) Write(p []byte) (n int, err error) { 113 | if socket.waitForOpenSocket() == false { 114 | return 0, errors.New("data socket unavailable") 115 | } 116 | return socket.conn.Write(p) 117 | } 118 | 119 | func (socket *ftpPassiveSocket) Close() error { 120 | socket.logger.Print("closing passive data socket") 121 | if socket.conn != nil { 122 | return socket.conn.Close() 123 | } 124 | return nil 125 | } 126 | 127 | func (socket *ftpPassiveSocket) ListenAndServe(minPort int, maxPort int) { 128 | listener, err := socket.netListenerInRange(minPort, maxPort) 129 | if err != nil { 130 | socket.logger.Print(err) 131 | return 132 | } 133 | defer listener.Close() 134 | add := listener.Addr().(*net.TCPAddr) 135 | socket.port = add.Port 136 | tcpConn, err := listener.AcceptTCP() 137 | if err != nil { 138 | socket.logger.Print(err) 139 | return 140 | } 141 | socket.conn = tcpConn 142 | } 143 | 144 | func (socket *ftpPassiveSocket) waitForOpenSocket() bool { 145 | retries := 0 146 | for { 147 | if socket.conn != nil { 148 | break 149 | } 150 | if retries > 3 { 151 | return false 152 | } 153 | socket.logger.Print("sleeping, socket isn't open") 154 | sleepMs := time.Duration(500 * (retries + 1)) 155 | time.Sleep(sleepMs * time.Millisecond) 156 | retries += 1 157 | } 158 | return true 159 | } 160 | 161 | func (socket *ftpPassiveSocket) netListenerInRange(min, max int) (*net.TCPListener, error) { 162 | for retries := 1; retries < 100; retries++ { 163 | port := randomPort(min, max) 164 | l, err := net.Listen("tcp", net.JoinHostPort(socket.Host(), strconv.Itoa(port))) 165 | if err == nil { 166 | return l.(*net.TCPListener), nil 167 | } 168 | } 169 | return nil, errors.New("Unable to find available port to listen on") 170 | } 171 | 172 | func randomPort(min, max int) int { 173 | if min == 0 && max == 0 { 174 | return 0 175 | } else { 176 | return min + rand.Intn(max-min-1) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /ftpdriver.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "time" 7 | ) 8 | 9 | // For each client that connects to the server, a new FTPDriver is required. 10 | // Create an implementation if this interface and provide it to FTPServer. 11 | type FTPDriverFactory interface { 12 | NewDriver() (FTPDriver, error) 13 | } 14 | 15 | // You will create an implementation of this interface that speaks to your 16 | // chosen persistence layer. graval will create a new instance of your 17 | // driver for each client that connects and delegate to it as required. 18 | type FTPDriver interface { 19 | // params - username, password 20 | // returns - true if the provided details are valid 21 | Authenticate(string, string) bool 22 | 23 | // params - a file path 24 | // returns - an int with the number of bytes in the file or -1 if the file 25 | // doesn't exist 26 | Bytes(string) int64 27 | 28 | // params - a file path 29 | // returns - a time indicating when the requested path was last modified 30 | // - an error if the file doesn't exist or the user lacks 31 | // permissions 32 | ModifiedTime(string) (time.Time, error) 33 | 34 | // params - path 35 | // returns - true if the current user is permitted to change to the 36 | // requested path 37 | ChangeDir(string) bool 38 | 39 | // params - path 40 | // returns - a collection of items describing the contents of the requested 41 | // path 42 | DirContents(string) []os.FileInfo 43 | 44 | // params - path 45 | // returns - true if the directory was deleted 46 | DeleteDir(string) bool 47 | 48 | // params - path 49 | // returns - true if the file was deleted 50 | DeleteFile(string) bool 51 | 52 | // params - from_path, to_path 53 | // returns - true if the file was renamed 54 | Rename(string, string) bool 55 | 56 | // params - path 57 | // returns - true if the new directory was created 58 | MakeDir(string) bool 59 | 60 | // params - path 61 | // returns - a Reader that will return file data to send to the client 62 | GetFile(string) (io.ReadCloser, error) 63 | 64 | // params - desination path, an io.Reader containing the file data 65 | // returns - true if the data was successfully persisted 66 | PutFile(string, io.Reader) bool 67 | } 68 | -------------------------------------------------------------------------------- /ftpfileinfo.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | "os" 5 | "time" 6 | ) 7 | 8 | type ftpFileInfo struct { 9 | name string 10 | bytes int64 11 | mode os.FileMode 12 | modtime time.Time 13 | } 14 | 15 | func (info *ftpFileInfo) Name() string { 16 | return info.name 17 | } 18 | 19 | func (info *ftpFileInfo) Size() int64 { 20 | return info.bytes 21 | } 22 | 23 | func (info *ftpFileInfo) Mode() os.FileMode { 24 | return info.mode 25 | } 26 | 27 | func (info *ftpFileInfo) ModTime() time.Time { 28 | return info.modtime 29 | } 30 | 31 | func (info *ftpFileInfo) IsDir() bool { 32 | return (info.mode & os.ModeDir) == os.ModeDir 33 | } 34 | 35 | func (info *ftpFileInfo) Sys() interface{} { 36 | return nil 37 | } 38 | 39 | // NewDirItem creates a new os.FileInfo that represents a single diretory. Use 40 | // this function to build the response to DirContents() in your FTPDriver 41 | // implementation. 42 | func NewDirItem(name string, modtime time.Time) os.FileInfo { 43 | d := new(ftpFileInfo) 44 | d.name = name 45 | d.bytes = int64(0) 46 | d.mode = os.ModeDir | 0666 47 | d.modtime = modtime 48 | return d 49 | } 50 | 51 | // NewFileItem creates a new os.FileInfo that represents a single file. Use 52 | // this function to build the response to DirContents() in your FTPDriver 53 | // implementation. 54 | func NewFileItem(name string, bytes int64, modtime time.Time) os.FileInfo { 55 | f := new(ftpFileInfo) 56 | f.name = name 57 | f.bytes = int64(bytes) 58 | f.mode = 0666 59 | f.modtime = modtime 60 | return f 61 | } 62 | -------------------------------------------------------------------------------- /ftpfileinfo_test.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | . "github.com/smartystreets/goconvey/convey" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestNewDirInfo(t *testing.T) { 11 | modTime := time.Unix(1566738000, 0) // 2019-08-25 13:00:00 UTC 12 | dirInfo := NewDirItem("dir", modTime) 13 | Convey("New Directory Info", t, func() { 14 | Convey("Will display the correct Mode", func() { 15 | So(dirInfo.Mode(), ShouldEqual, os.ModeDir|0666) 16 | }) 17 | 18 | Convey("Will display the correct Name", func() { 19 | So(dirInfo.Name(), ShouldEqual, "dir") 20 | }) 21 | 22 | Convey("Will display a size of 0 bytes", func() { 23 | So(dirInfo.Size(), ShouldEqual, 0) 24 | }) 25 | 26 | Convey("Will display modified date as current time", func() { 27 | So(dirInfo.ModTime(), ShouldEqual, modTime) 28 | }) 29 | 30 | Convey("Will return nil for Sys", func() { 31 | So(dirInfo.Sys(), ShouldBeNil) 32 | }) 33 | }) 34 | } 35 | 36 | func TestNewFileInfo(t *testing.T) { 37 | modTime := time.Unix(1566738000, 0) // 2019-08-25 13:00:00 UTC 38 | dirInfo := NewFileItem("test.txt", int64(99), modTime) 39 | Convey("New File Info", t, func() { 40 | Convey("Will display the correct Mode", func() { 41 | So(dirInfo.Mode(), ShouldEqual, 0666) 42 | }) 43 | 44 | Convey("Will display the correct Name", func() { 45 | So(dirInfo.Name(), ShouldEqual, "test.txt") 46 | }) 47 | 48 | Convey("Will display a size of 0 bytes", func() { 49 | So(dirInfo.Size(), ShouldEqual, 99) 50 | }) 51 | 52 | Convey("Will display modified date as current time", func() { 53 | So(dirInfo.ModTime(), ShouldEqual, modTime) 54 | }) 55 | 56 | Convey("Will return nil for Sys", func() { 57 | So(dirInfo.Sys(), ShouldBeNil) 58 | }) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /ftplogger.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | ) 7 | 8 | // Use an instance of this to log in a standard format 9 | type ftpLogger struct { 10 | sessionId string 11 | } 12 | 13 | func newFtpLogger(id string) *ftpLogger { 14 | l := new(ftpLogger) 15 | l.sessionId = id 16 | return l 17 | } 18 | 19 | func (logger *ftpLogger) Print(message interface{}) { 20 | log.Printf("%s %s", logger.sessionId, message) 21 | } 22 | 23 | func (logger *ftpLogger) Printf(format string, v ...interface{}) { 24 | logger.Print(fmt.Sprintf(format, v...)) 25 | } 26 | 27 | func (logger *ftpLogger) PrintCommand(command string, params string) { 28 | if command == "PASS" { 29 | log.Printf("%s > PASS ****", logger.sessionId) 30 | } else { 31 | log.Printf("%s > %s %s", logger.sessionId, command, params) 32 | } 33 | } 34 | 35 | func (logger *ftpLogger) PrintResponse(code int, message string) { 36 | log.Printf("%s < %d %s", logger.sessionId, code, message) 37 | } 38 | -------------------------------------------------------------------------------- /ftpserver.go: -------------------------------------------------------------------------------- 1 | // An experimental FTP server framework. By providing a simple driver class that 2 | // responds to a handful of methods you can have a complete FTP server. 3 | // 4 | // Some sample use cases include persisting data to an Amazon S3 bucket, a 5 | // relational database, redis or memory. 6 | // 7 | // There is a sample in-memory driver available - see the documentation for the 8 | // graval-mem package or the graval READEME for more details. 9 | package graval 10 | 11 | import ( 12 | "net" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | // serverOpts contains parameters for graval.NewFTPServer() 19 | type FTPServerOpts struct { 20 | // Server name will be used for welcome message 21 | ServerName string 22 | 23 | // The factory that will be used to create a new FTPDriver instance for 24 | // each client connection. This is a mandatory option. 25 | Factory FTPDriverFactory 26 | 27 | // The hostname that the FTP server should listen on. Optional, defaults to 28 | // "::", which means all hostnames on ipv4 and ipv6. 29 | Hostname string 30 | 31 | // The port that the FTP should listen on. Optional, defaults to 3000. In 32 | // a production environment you will probably want to change this to 21. 33 | Port int 34 | 35 | // The lower bound of port numbers that can be used for passive-mode data sockets 36 | // Defaults to 0, which allows the server to pick any free port 37 | PasvMinPort int 38 | 39 | // The upper bound of port numbers that can be used for passive-mode data sockets 40 | // Defaults to 0, which allows the server to pick any free port 41 | PasvMaxPort int 42 | 43 | // Use this option to override the IP address that will be advertised in response to the 44 | // PASV command. Most setups can ignore this, but it can be helpful in situations where 45 | // the FTP server is behind a NAT gateway or load balancer and the public IP used by 46 | // clients is different to the IP the server is directly listening on 47 | PasvAdvertisedIp string 48 | } 49 | 50 | // FTPServer is the root of your FTP application. You should instantiate one 51 | // of these and call ListenAndServe() to start accepting client connections. 52 | // 53 | // Always use the NewFTPServer() method to create a new FTPServer. 54 | type FTPServer struct { 55 | serverName string 56 | listenTo string 57 | driverFactory FTPDriverFactory 58 | logger *ftpLogger 59 | pasvMinPort int 60 | pasvMaxPort int 61 | pasvAdvertisedIp string 62 | closeChan chan struct{} 63 | } 64 | 65 | // serverOptsWithDefaults copies an FTPServerOpts struct into a new struct, 66 | // then adds any default values that are missing and returns the new data. 67 | func serverOptsWithDefaults(opts *FTPServerOpts) *FTPServerOpts { 68 | var newOpts FTPServerOpts 69 | 70 | if opts == nil { 71 | opts = &FTPServerOpts{} 72 | } 73 | 74 | if opts.ServerName == "" { 75 | newOpts.ServerName = "Go FTP Server" 76 | } else { 77 | newOpts.ServerName = opts.ServerName 78 | } 79 | 80 | if opts.Hostname == "" { 81 | newOpts.Hostname = "::" 82 | } else { 83 | newOpts.Hostname = opts.Hostname 84 | } 85 | 86 | if opts.Port == 0 { 87 | newOpts.Port = 3000 88 | } else { 89 | newOpts.Port = opts.Port 90 | } 91 | 92 | newOpts.PasvMinPort = opts.PasvMinPort 93 | newOpts.PasvMaxPort = opts.PasvMaxPort 94 | newOpts.PasvAdvertisedIp = opts.PasvAdvertisedIp 95 | newOpts.Factory = opts.Factory 96 | 97 | return &newOpts 98 | } 99 | 100 | // NewFTPServer initialises a new FTP server. Configuration options are provided 101 | // via an instance of FTPServerOpts. Calling this function in your code will 102 | // probably look something like this: 103 | // 104 | // factory := &MyDriverFactory{} 105 | // server := graval.NewFTPServer(&graval.FTPServerOpts{ Factory: factory }) 106 | // 107 | // or: 108 | // 109 | // factory := &MyDriverFactory{} 110 | // opts := &graval.FTPServerOpts{ 111 | // Factory: factory, 112 | // Port: 2000, 113 | // Hostname: "127.0.0.1", 114 | // } 115 | // server := graval.NewFTPServer(opts) 116 | // 117 | func NewFTPServer(opts *FTPServerOpts) *FTPServer { 118 | opts = serverOptsWithDefaults(opts) 119 | s := new(FTPServer) 120 | s.listenTo = buildTcpString(opts.Hostname, opts.Port) 121 | s.serverName = opts.ServerName 122 | s.driverFactory = opts.Factory 123 | s.logger = newFtpLogger("") 124 | s.pasvMinPort = opts.PasvMinPort 125 | s.pasvMaxPort = opts.PasvMaxPort 126 | s.pasvAdvertisedIp = opts.PasvAdvertisedIp 127 | s.closeChan = make(chan struct{}) 128 | return s 129 | } 130 | 131 | // ListenAndServe asks a new FTPServer to begin accepting client connections. It 132 | // accepts no arguments - all configuration is provided via the NewFTPServer 133 | // function. 134 | // 135 | // If the server fails to start for any reason, an error will be returned. Common 136 | // errors are trying to bind to a privileged port or something else is already 137 | // listening on the same port. 138 | // 139 | func (ftpServer *FTPServer) ListenAndServe() error { 140 | laddr, err := net.ResolveTCPAddr("tcp", ftpServer.listenTo) 141 | if err != nil { 142 | return err 143 | } 144 | listener, err := net.ListenTCP("tcp", laddr) 145 | if err != nil { 146 | return err 147 | } 148 | ftpServer.logger.Printf("listening on %s", listener.Addr().String()) 149 | 150 | for { 151 | select { 152 | case <-ftpServer.closeChan: 153 | listener.Close() 154 | return nil 155 | default: 156 | listener.SetDeadline(time.Now().Add(2 * time.Second)) 157 | tcpConn, err := listener.AcceptTCP() 158 | if err != nil && strings.HasSuffix(err.Error(), "i/o timeout") { 159 | // deadline reached, no big deal 160 | // NOTE: This error is passed from the internal/poll/ErrTimeout but that 161 | // package is not legal to include, hence the string match. :( 162 | continue 163 | } else if err != nil { 164 | ftpServer.logger.Printf("listening error: %+v", err) 165 | return err 166 | } 167 | 168 | driver, err := ftpServer.driverFactory.NewDriver() 169 | if err != nil { 170 | ftpServer.logger.Print("Error creating driver, aborting client connection") 171 | } else { 172 | ftpConn := newftpConn(tcpConn, driver, ftpServer.serverName, ftpServer.pasvMinPort, ftpServer.pasvMaxPort, ftpServer.pasvAdvertisedIp) 173 | go ftpConn.Serve() 174 | } 175 | 176 | } 177 | } 178 | return nil 179 | } 180 | 181 | // Close signals the server to stop. It may take a couple of seconds. Do not call ListenAndServe again after this, build a new FTPServer. 182 | func (ftpServer *FTPServer) Close() { 183 | select { 184 | case <-ftpServer.closeChan: 185 | // already closed 186 | default: 187 | close(ftpServer.closeChan) 188 | } 189 | } 190 | 191 | func buildTcpString(hostname string, port int) (result string) { 192 | if strings.Contains(hostname, ":") { 193 | // ipv6 194 | if port == 0 { 195 | result = "[" + hostname + "]" 196 | } else { 197 | result = "[" + hostname + "]:" + strconv.Itoa(port) 198 | } 199 | } else { 200 | // ipv4 201 | if port == 0 { 202 | result = hostname 203 | } else { 204 | result = hostname + ":" + strconv.Itoa(port) 205 | } 206 | } 207 | return 208 | } 209 | -------------------------------------------------------------------------------- /ftpserver_test.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | . "github.com/smartystreets/goconvey/convey" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestClose(t *testing.T) { 10 | 11 | goneChan := make(chan struct{}) 12 | 13 | Convey("Setting up a minimal server, it will end if Close() is called ", t, func() { 14 | opts := &FTPServerOpts{ 15 | ServerName: "blah blah blah", 16 | PasvMinPort: 60200, 17 | PasvMaxPort: 60300, 18 | } 19 | ftpServer := NewFTPServer(opts) 20 | go func() { 21 | defer close(goneChan) 22 | err := ftpServer.ListenAndServe() 23 | if err != nil { 24 | panic(err) 25 | } 26 | }() 27 | time.Sleep(1 * time.Second) 28 | So(ftpServer.Close, ShouldNotPanic) 29 | <-goneChan 30 | 31 | }) 32 | } 33 | 34 | func TestCloseLater(t *testing.T) { 35 | t.Skip("Waits a while, which you don't need to") 36 | goneChan := make(chan struct{}) 37 | 38 | Convey("Setting up a minimal server, it will end if Close() is called ", t, func() { 39 | opts := &FTPServerOpts{ 40 | ServerName: "blah blah blah", 41 | PasvMinPort: 60200, 42 | PasvMaxPort: 60300, 43 | } 44 | ftpServer := NewFTPServer(opts) 45 | go func() { 46 | defer close(goneChan) 47 | err := ftpServer.ListenAndServe() 48 | if err != nil { 49 | panic(err) 50 | } 51 | }() 52 | time.Sleep(10 * time.Second) 53 | So(ftpServer.Close, ShouldNotPanic) 54 | <-goneChan 55 | 56 | }) 57 | } 58 | 59 | func TestCloseHammer(t *testing.T) { 60 | 61 | goneChan := make(chan struct{}) 62 | 63 | Convey("Setting up a minimal server, it will end if Close() is called a bunch of times", t, func() { 64 | opts := &FTPServerOpts{ 65 | ServerName: "blah blah blah", 66 | PasvMinPort: 60200, 67 | PasvMaxPort: 60300, 68 | } 69 | ftpServer := NewFTPServer(opts) 70 | go func() { 71 | defer close(goneChan) 72 | err := ftpServer.ListenAndServe() 73 | if err != nil { 74 | panic(err) 75 | } 76 | }() 77 | time.Sleep(1 * time.Second) 78 | So(ftpServer.Close, ShouldNotPanic) 79 | So(ftpServer.Close, ShouldNotPanic) 80 | So(ftpServer.Close, ShouldNotPanic) 81 | So(ftpServer.Close, ShouldNotPanic) 82 | So(ftpServer.Close, ShouldNotPanic) 83 | So(ftpServer.Close, ShouldNotPanic) 84 | So(ftpServer.Close, ShouldNotPanic) 85 | So(ftpServer.Close, ShouldNotPanic) 86 | 87 | <-goneChan 88 | 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yob/graval 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869 7 | github.com/smartystreets/goconvey v1.6.4 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 2 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 3 | github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869 h1:IPJ3dvxmJ4uczJe5YQdrYB16oTJlGSC/OyZDqUk9xX4= 4 | github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869/go.mod h1:cJ6Cj7dQo+O6GJNiMx+Pa94qKj+TG8ONdKHgMNIyyag= 5 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 6 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 7 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 8 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 9 | github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 h1:WN9BUFbdyOsSH/XohnWpXOlq9NBD5sGAB2FciQMUEe8= 10 | github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 11 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 12 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 13 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 14 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 15 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 16 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 17 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1P+/ZFC/IKythI5RzrnRg= 18 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 19 | -------------------------------------------------------------------------------- /graval-mem/graval-mem.go: -------------------------------------------------------------------------------- 1 | // An example FTP server build on top of go-raval. graval handles the details 2 | // of the FTP protocol, we just provide a basic in-memory persistence driver. 3 | // 4 | // If you're looking to create a custom graval driver, this example is a 5 | // reasonable starting point. I suggest copying this file and changing the 6 | // function bodies as required. 7 | // 8 | // USAGE: 9 | // 10 | // go get github.com/yob/graval 11 | // go install github.com/yob/graval/graval-mem 12 | // ./bin/graval-mem 13 | // 14 | package main 15 | 16 | import ( 17 | "github.com/yob/graval" 18 | "io" 19 | "io/ioutil" 20 | "log" 21 | "os" 22 | "os/signal" 23 | "strings" 24 | "syscall" 25 | "time" 26 | ) 27 | 28 | const ( 29 | fileOne = "This is the first file available for download.\n\nBy Jàmes" 30 | fileTwo = "This is file number two.\n\n2012-12-04" 31 | ) 32 | 33 | // A minimal driver for graval that stores everything in memory. The authentication 34 | // details are fixed and the user is unable to upload, delete or rename any files. 35 | // 36 | // This really just exists as a minimal demonstration of the interface graval 37 | // drivers are required to implement. 38 | type MemDriver struct{} 39 | 40 | func (driver *MemDriver) Authenticate(user string, pass string) bool { 41 | return user == "test" && pass == "1234" 42 | } 43 | func (driver *MemDriver) Bytes(path string) (bytes int64) { 44 | switch path { 45 | case "/one.txt": 46 | bytes = int64(len(fileOne)) 47 | break 48 | case "/files/two.txt": 49 | bytes = int64(len(fileTwo)) 50 | break 51 | default: 52 | bytes = -1 53 | } 54 | return 55 | } 56 | func (driver *MemDriver) ModifiedTime(path string) (time.Time, error) { 57 | return time.Now(), nil 58 | } 59 | func (driver *MemDriver) ChangeDir(path string) bool { 60 | return path == "/" || path == "/files" 61 | } 62 | func (driver *MemDriver) DirContents(path string) (files []os.FileInfo) { 63 | files = []os.FileInfo{} 64 | switch path { 65 | case "/": 66 | files = append(files, graval.NewDirItem("files", time.Now())) 67 | files = append(files, graval.NewFileItem("one.txt", int64(len(fileOne)), time.Now())) 68 | case "/files": 69 | files = append(files, graval.NewFileItem("two.txt", int64(len(fileOne)), time.Now())) 70 | } 71 | return files 72 | } 73 | 74 | func (driver *MemDriver) DeleteDir(path string) bool { 75 | return false 76 | } 77 | func (driver *MemDriver) DeleteFile(path string) bool { 78 | return false 79 | } 80 | func (driver *MemDriver) Rename(fromPath string, toPath string) bool { 81 | return false 82 | } 83 | func (driver *MemDriver) MakeDir(path string) bool { 84 | return false 85 | } 86 | func (driver *MemDriver) GetFile(path string) (reader io.ReadCloser, err error) { 87 | switch path { 88 | case "/one.txt": 89 | reader = ioutil.NopCloser(strings.NewReader(fileOne)) 90 | case "/files/two.txt": 91 | reader = ioutil.NopCloser(strings.NewReader(fileTwo)) 92 | } 93 | return 94 | } 95 | func (driver *MemDriver) PutFile(destPath string, data io.Reader) bool { 96 | return false 97 | } 98 | 99 | // graval requires a factory that will create a new driver instance for each 100 | // client connection. Generally the factory will be fairly minimal. This is 101 | // a good place to read any required config for your driver. 102 | type MemDriverFactory struct{} 103 | 104 | func (factory *MemDriverFactory) NewDriver() (graval.FTPDriver, error) { 105 | return &MemDriver{}, nil 106 | } 107 | 108 | // it's alive! 109 | func main() { 110 | factory := &MemDriverFactory{} 111 | opts := &graval.FTPServerOpts{ 112 | Factory: factory, 113 | ServerName: "graval-mem, the in memory FTP server", 114 | PasvMinPort: 60200, 115 | PasvMaxPort: 60300, 116 | } 117 | ftpServer := graval.NewFTPServer(opts) 118 | 119 | c := make(chan os.Signal) 120 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 121 | signal.Notify(c, os.Interrupt, syscall.SIGQUIT) 122 | go func() { 123 | <-c 124 | log.Println("Exiting...") 125 | ftpServer.Close() 126 | os.Exit(1) 127 | }() 128 | 129 | err := ftpServer.ListenAndServe() 130 | if err != nil { 131 | log.Print(err) 132 | log.Fatal("Error starting server!") 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /listformatter.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | "github.com/jehiah/go-strftime" 5 | "os" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | type listFormatter struct { 11 | files []os.FileInfo 12 | } 13 | 14 | func newListFormatter(files []os.FileInfo) *listFormatter { 15 | f := new(listFormatter) 16 | f.files = files 17 | return f 18 | } 19 | 20 | // Short returns a string that lists the collection of files by name only, 21 | // one per line 22 | func (formatter *listFormatter) Short() string { 23 | output := "" 24 | for _, file := range formatter.files { 25 | output += file.Name() + "\r\n" 26 | } 27 | output += "\r\n" 28 | return output 29 | } 30 | 31 | // Detailed returns a string that lists the collection of files with extra 32 | // detail, one per line 33 | func (formatter *listFormatter) Detailed() string { 34 | output := "" 35 | for _, file := range formatter.files { 36 | output += file.Mode().String() 37 | output += " 1 owner group " 38 | output += lpad(strconv.Itoa(int(file.Size())), 12) 39 | output += " " + strftime.Format("%b %d %H:%M", file.ModTime().UTC()) 40 | output += " " + file.Name() 41 | output += "\r\n" 42 | } 43 | output += "\r\n" 44 | return output 45 | } 46 | 47 | func lpad(input string, length int) (result string) { 48 | if len(input) < length { 49 | result = strings.Repeat(" ", length-len(input)) + input 50 | } else if len(input) == length { 51 | result = input 52 | } else { 53 | result = input[0:length] 54 | } 55 | return 56 | } 57 | -------------------------------------------------------------------------------- /listformatter_test.go: -------------------------------------------------------------------------------- 1 | package graval 2 | 3 | import ( 4 | . "github.com/smartystreets/goconvey/convey" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | type TestFileInfo struct{} 11 | 12 | func (t *TestFileInfo) Name() string { 13 | return "file1.txt" 14 | } 15 | 16 | func (t *TestFileInfo) Size() int64 { 17 | return 99 18 | } 19 | 20 | func (t *TestFileInfo) Mode() os.FileMode { 21 | return os.ModeSymlink 22 | } 23 | 24 | func (t *TestFileInfo) IsDir() bool { 25 | return false 26 | } 27 | 28 | func (t *TestFileInfo) ModTime() time.Time { 29 | return time.Unix(1, 0) 30 | } 31 | 32 | func (t *TestFileInfo) Sys() interface{} { 33 | return nil 34 | } 35 | 36 | var files []os.FileInfo = []os.FileInfo{ 37 | &TestFileInfo{}, &TestFileInfo{}, 38 | } 39 | 40 | func TestShortFormat(t *testing.T) { 41 | formatter := newListFormatter(files) 42 | Convey("The Short listing format", t, func() { 43 | Convey("Will display correctly", func() { 44 | So(formatter.Short(), ShouldEqual, "file1.txt\r\nfile1.txt\r\n\r\n") 45 | }) 46 | }) 47 | } 48 | 49 | func TestDetailedFormat(t *testing.T) { 50 | formatter := newListFormatter(files) 51 | Convey("The Detailed listing format", t, func() { 52 | Convey("Will display correctly", func() { 53 | So(formatter.Detailed(), ShouldEqual, "L--------- 1 owner group 99 Jan 01 00:00 file1.txt\r\nL--------- 1 owner group 99 Jan 01 00:00 file1.txt\r\n\r\n") 54 | }) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /scripts/run-mem: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | go build -o bin/graval-mem ./graval-mem 4 | ./bin/graval-mem 5 | --------------------------------------------------------------------------------