├── README.md ├── .gitignore ├── go-ftp.go ├── LICENSE └── server ├── utils.go ├── login.go ├── response.go └── server.go /README.md: -------------------------------------------------------------------------------- 1 | # go-ftp 2 | I'm learning Go and wanted to try and write a basic FTP server 3 | 4 | ## To demo 5 | 6 | Run `./go-ftp` in one tab and `ftp -vv -4 -u ftp://username:bassackwards@127.0.0.1:2121/ ./README` in another 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # output 11 | uploads/ 12 | 13 | # Architecture specific extensions/prefixes 14 | *.[568vq] 15 | [568vq].out 16 | 17 | *.cgo1.go 18 | *.cgo2.c 19 | _cgo_defun.c 20 | _cgo_gotypes.go 21 | _cgo_export.* 22 | 23 | _testmain.go 24 | 25 | *.exe 26 | *.test 27 | *.prof 28 | 29 | .idea/ 30 | -------------------------------------------------------------------------------- /go-ftp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/micahhausler/go-ftp/server" 6 | "net" 7 | "os" 8 | ) 9 | 10 | func main() { 11 | fmt.Println("Starting up FTP server") 12 | port := ":2121" 13 | ln, err := net.Listen("tcp", port) 14 | if err != nil { 15 | fmt.Println(err) 16 | os.Exit(1) 17 | } 18 | 19 | for { 20 | c, err := ln.Accept() 21 | if err != nil { 22 | fmt.Println(err) 23 | continue 24 | } 25 | 26 | fmt.Printf("Connection from %v established.\n", c.RemoteAddr()) 27 | go server.HandleConnection(c) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Micah Hausler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/utils.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | func stringInList(search string, data []string) bool { 12 | for _, d := range data { 13 | if search == d { 14 | return true 15 | } 16 | } 17 | return false 18 | } 19 | 20 | func parsePortArgs(arg string) string { 21 | // Parses a PORT argument and returns an IP addr and port 22 | // Ex: "10,0,0,1,192,127" and returns "10.0.0.1:49279" 23 | parts := strings.Split(arg, ",") 24 | ip := strings.Join(parts[:4], ".") 25 | p1, _ := strconv.Atoi(parts[4]) 26 | p2, _ := strconv.Atoi(parts[5]) 27 | port := p1*256 + p2 28 | 29 | return fmt.Sprintf("%s:%d", ip, port) 30 | } 31 | 32 | func stripDirectory(remoteName string) string { 33 | _, filename := path.Split(remoteName) 34 | return filename 35 | } 36 | 37 | func parseCommand(input string) (string, string, error) { 38 | // Split out command and arguments 39 | var command, args string 40 | var err error 41 | 42 | // commands are all 3 or 4 characters 43 | if len(input) < 3 { 44 | return command, args, errors.New(SyntaxErr) 45 | } 46 | 47 | response := strings.SplitAfterN(input, " ", 2) 48 | 49 | switch { 50 | case len(response) == 2: 51 | command = strings.TrimSpace(response[0]) 52 | args = strings.TrimSpace(response[1]) 53 | case len(response) == 1: 54 | command = response[0] 55 | } 56 | return command, args, err 57 | } 58 | -------------------------------------------------------------------------------- /server/login.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | /* 4 | import ( 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | ) 9 | */ 10 | 11 | type AuthUser struct { 12 | username string 13 | password string 14 | valid bool 15 | } 16 | 17 | func handleLogin(message string, user *AuthUser) string { 18 | // Handle login operations 19 | 20 | cmd, args, err := parseCommand(message) 21 | if err != nil { 22 | return SyntaxErr 23 | } 24 | 25 | switch { 26 | case cmd == "USER" && args == "": 27 | return AnonUserDenied 28 | case cmd == "USER" && args != "": 29 | user.username = args 30 | return UsrNameOkNeedPass 31 | case cmd == "PASS" && args == "": 32 | return SyntaxErr 33 | case cmd == "PASS" && args != "" && user.username != "": 34 | user.password = args 35 | } 36 | 37 | user.Authenticate() 38 | 39 | if user.valid == true { 40 | return UsrLoggedInProceed 41 | } else { 42 | user.username = "" 43 | user.password = "" 44 | return AuthFailureTryAgain 45 | } 46 | } 47 | 48 | func (user *AuthUser) Authenticate() { 49 | // Authenticate user against data.ambition 50 | 51 | /* 52 | uri := fmt.Sprint(DataUrl, "/api/upload/ftp-auth/") 53 | resp, err := http.PostForm(uri, 54 | url.Values{ 55 | "username": {user.username}, 56 | "password": {user.password}}) 57 | 58 | if resp.StatusCode == http.StatusOK && err == nil { 59 | user.valid = true 60 | } else { 61 | user.valid = false 62 | } 63 | */ 64 | user.valid = true 65 | } 66 | -------------------------------------------------------------------------------- /server/response.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | const ( 4 | DataCnxAlreadyOpenStartXfr = "125 Data connection already open, starting transfer\r\n" 5 | TypeSetOk = "200 Type set ok\r\n" 6 | PortOk = "200 PORT ok\r\n" 7 | FeatResponse = "211-Features:\r\n FEAT\r\n MDTM\r\n PASV\r\n SIZE\r\n TYPE A;I\r\n211 End\r\n" 8 | SysType = "215 UNIX Type: L8\r\n" 9 | GoodbyeMsg = "221 Goodbye!" 10 | TxfrCompleteOk = "226 Data transfer complete\r\n" 11 | CmdOk = "200 Command ok\r\n" 12 | EnteringPasvMode = "227 Entering Passive Mode (%s)\r\n" 13 | PwdResponse = "257 \"/\"\r\n" 14 | FtpServerReady = "220 FTP Server Ready\r\n" 15 | UsrLoggedInProceed = "230 User Logged In Proceed\r\n" 16 | UsrNameOkNeedPass = "331 Username OK Need Pass\r\n" 17 | SyntaxErr = "500 Syntax Error\r\n" 18 | CmdNotImplmntd = "502 Command not implemented\r\n" 19 | NotLoggedIn = "530 Not Logged In\r\n" 20 | AuthFailure = "530 Auth Failure\r\n" 21 | AuthFailureTryAgain = "530 Please login with USER and PASS." 22 | AnonUserDenied = "550 Anon User Denied\r\n" 23 | ) 24 | 25 | /* 26 | const ( 27 | ServiceReadyInNMinutes = 120 28 | DataCnxAlreadyOpenStartXfr = 125 29 | FileStatusOkOpenDataCnx = 150 30 | CmdOk = 200.1 31 | TypeSetOk = 200.2 32 | EnteringPortMode = 200.3 33 | CmdNotImplmntdSuperfluous = 202 34 | SysStatusOrHelpReply = 211.1 35 | FeatOk = 211.2 36 | DirStatus = 212 37 | FileStatus = 213 38 | HelpMsg = 214 39 | NameSysType = 215 40 | SvcReadyForNewUser = 220.1 41 | WelcomeMsg = 220.2 42 | SvcClosingCtrlCnx = 221.1 43 | GoodbyeMsg = 221.2 44 | DataCnxOpenNoXfrInProgress = 225 45 | ClosingDataCnx = 226.1 46 | TxfrCompleteOk = 226.2 47 | EnteringPasvMode = 227 48 | EnteringEpsvMode = 229 49 | UsrLoggedInProceed = 230.1 50 | GuestLoggedInProceed = 230.2 51 | ReqFileActnCompletedOk = 250 52 | PwdReply = 257.1 53 | MkdReply = 257.2 54 | UsrNameOkNeedPass = 331.1 55 | GuestNameOkNeedEmail = 331.2 56 | NeedAcctForLogin = 332 57 | ReqFileActnPendingFurtherInfo = 350 58 | SvcNotAvailClosingCtrlCnx = 421.1 59 | TooManyConnections = 421.2 60 | CantOpenDataCnx = 425 61 | CnxClosedTxfrAborted = 426 62 | ReqActnAbrtdFileUnavail = 450 63 | ReqActnAbrtdLocalErr = 451 64 | ReqActnAbrtdInsuffStorage = 452 65 | SyntaxErr = 500 66 | SyntaxErrInArgs = 501 67 | CmdNotImplmntd = 502.1 68 | OptsNotImplemented = 502.2 69 | BadCmdSeq = 503 70 | CmdNotImplmntdForParam = 504 71 | NotLoggedIn = 530.1 72 | AuthFailure = 530.2 73 | NeedAcctForStor = 532 74 | FileNotFound = 550.1 75 | PermissionDenied = 550.2 76 | AnonUserDenied = 550.3 77 | IsNotADir = 550.4 78 | ReqActnNotTaken = 550.5 79 | FileExists = 550.6 80 | IsADir = 550.7 81 | PageTypeUnk = 551 82 | ExceededStorageAlloc = 552 83 | FilenameNotAllowed = 553 84 | ) 85 | */ 86 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "path" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | const ( 18 | DataUrl = "https://data.ambition.io" 19 | storageDir = "uploads" 20 | ) 21 | 22 | type ConnectionConfig struct { 23 | DataConnectionAddr string 24 | Filename string 25 | } 26 | 27 | func HandleConnection(c net.Conn) { 28 | // Handle a connection from a client 29 | defer c.Close() 30 | 31 | sendMsg(c, FtpServerReady) 32 | user := AuthUser{} 33 | 34 | for { 35 | message := getMsg(c) 36 | response := handleLogin(message, &user) 37 | sendMsg(c, response) 38 | if user.valid == true { 39 | break 40 | } 41 | } 42 | 43 | config := ConnectionConfig{} 44 | 45 | for { 46 | cmd := getMsg(c) 47 | response, err := handleCommand(cmd, &config, &user, c) 48 | if err != nil { 49 | break 50 | } 51 | sendMsg(c, response) 52 | time.Sleep(100 * time.Millisecond) 53 | } 54 | } 55 | 56 | func handleCommand(input string, ch *ConnectionConfig, user *AuthUser, c net.Conn) (string, error) { 57 | // Handles input after authentication 58 | 59 | input = strings.TrimSpace(input) 60 | cmd, args, err := parseCommand(input) 61 | if err != nil { 62 | fmt.Printf("%s from %v: %s\n", SyntaxErr, c.RemoteAddr(), input) 63 | return SyntaxErr, err 64 | } 65 | 66 | ignoredCommands := []string{ 67 | "CDUP", // cd to parent dir 68 | "RMD", // remove directory 69 | "RNFR", // rename file from 70 | "RNTO", // rename file to 71 | "SITE", // execute arbitrary command 72 | "SIZE", // Size of a file 73 | "STAT", // Get status of FTP server 74 | } 75 | notImplemented := []string{ 76 | "EPSV", 77 | "EPRT", 78 | } 79 | 80 | switch { 81 | case stringInList(cmd, ignoredCommands): 82 | return CmdNotImplmntd, nil 83 | case stringInList(cmd, notImplemented): 84 | return CmdNotImplmntd, nil 85 | case cmd == "NOOP": 86 | return CmdOk, nil 87 | case cmd == "SYST": 88 | return SysType, nil 89 | case cmd == "STOR": 90 | ch.Filename = stripDirectory(args) 91 | readPortData(ch, user.username, c) 92 | // Don't upload for now 93 | //go uploadData(user, getFileName(user.username, ch.Filename)) 94 | return TxfrCompleteOk, nil 95 | case cmd == "FEAT": 96 | return FeatResponse, nil 97 | case cmd == "PWD": 98 | return PwdResponse, nil 99 | case cmd == "TYPE" && args == "I": 100 | return TypeSetOk, nil 101 | case cmd == "PORT": 102 | ch.DataConnectionAddr = parsePortArgs(args) 103 | return PortOk, nil 104 | case cmd == "PASV": 105 | // todo set up PASV mode 106 | //return EnteringPasvMode, nil 107 | return CmdNotImplmntd, nil 108 | case cmd == "QUIT": 109 | return GoodbyeMsg, nil 110 | } 111 | return "", nil 112 | } 113 | 114 | func uploadData(user *AuthUser, filePath string) { 115 | // Upload to data.ambition 116 | 117 | content, err := ioutil.ReadFile(filePath) 118 | if err != nil { 119 | fmt.Printf("Error reading file %s: %s\n", filePath, err) 120 | return 121 | } 122 | _, filename := path.Split(filePath) 123 | 124 | uri := fmt.Sprint(DataUrl, "/api/upload/ftp-upload/") 125 | 126 | resp, err := http.PostForm(uri, 127 | url.Values{ 128 | "username": {user.username}, 129 | "password": {user.password}, 130 | "local_filename": {filename}, 131 | "data": {string(content)}}) 132 | 133 | if resp.StatusCode == http.StatusCreated && err == nil { 134 | fmt.Printf("File %s uploaded to data!\n", filename) 135 | err = os.Remove(filePath) 136 | if err != nil { 137 | fmt.Printf("Error removing file %s: %s\n", filePath, err) 138 | return 139 | } 140 | } else { 141 | fmt.Printf("Could not upload '%s' to data! %s\n", filePath, err) 142 | } 143 | 144 | } 145 | 146 | func getFileName(username, filename string) string { 147 | return path.Join(storageDir, username, filename) 148 | } 149 | 150 | func readPortData(ch *ConnectionConfig, username string, out net.Conn) { 151 | // Read data from the client, write out to file 152 | fmt.Printf("connecting to %s\n", ch.DataConnectionAddr) 153 | 154 | var err error 155 | 156 | c, err := net.Dial("tcp", ch.DataConnectionAddr) 157 | // set timeout of one minute 158 | c.SetReadDeadline(time.Now().Add(time.Minute)) 159 | defer c.Close() 160 | if err != nil { 161 | fmt.Printf("connection to %s errored out: %s\n", ch.DataConnectionAddr, err) 162 | return 163 | } 164 | sendMsg(out, DataCnxAlreadyOpenStartXfr) 165 | 166 | err = os.MkdirAll(path.Join(storageDir, username), 0777) 167 | if err != nil { 168 | fmt.Printf("error creating dir: %s\n", err) 169 | return 170 | } 171 | 172 | outputName := getFileName(username, ch.Filename) 173 | file, err := os.Create(outputName) 174 | defer file.Close() 175 | if err != nil { 176 | fmt.Printf("error creating file '%s': %s\n", outputName, err) 177 | return 178 | } 179 | 180 | reader := bufio.NewReader(c) 181 | buf := make([]byte, 1024) // big buffer 182 | for { 183 | n, err := reader.Read(buf) 184 | if err != nil && err != io.EOF { 185 | fmt.Println("read error:", err) 186 | break 187 | } 188 | if n == 0 { 189 | break 190 | } 191 | if _, err := file.Write(buf[:n]); err != nil { 192 | fmt.Println("read error:", err) 193 | break 194 | } 195 | } 196 | } 197 | 198 | func getMsg(conn net.Conn) string { 199 | // Split the response into CMD and ARGS 200 | bufc := bufio.NewReader(conn) 201 | for { 202 | line, err := bufc.ReadString('\n') 203 | if err != nil { 204 | conn.Close() 205 | break 206 | } 207 | fmt.Printf("Received: %s\n", line) 208 | return strings.TrimRight(line, "\r") 209 | } 210 | return "" 211 | } 212 | 213 | func sendMsg(c net.Conn, message string) { 214 | fmt.Printf("Sending: %s\n", message) 215 | io.WriteString(c, message) 216 | } 217 | --------------------------------------------------------------------------------