516 lines
18 KiB
Lua
516 lines
18 KiB
Lua
--[[SPLIT MODULE ftp]]
|
|
|
|
--[[ A simple ftp server
|
|
|
|
This is my implementation of a FTP server using Github user Neronix's
|
|
example as inspriration, but as a cleaner Lua implementation that is
|
|
suitable for use in LFS. The coding style adopted here is more similar to
|
|
best practice for normal (PC) module implementations, as using LFS enables
|
|
me to bias towards clarity of coding over brevity. It includes extra logic
|
|
to handle some of the edge case issues more robustly. It also uses a
|
|
standard forward reference coding pattern to allow the code to be laid out
|
|
in main routine, subroutine order.
|
|
|
|
The app will only call one FTP.open() or FTP.createServer() at any time,
|
|
with any multiple calls requected, so FTP is a singleton static object.
|
|
However there is nothing to stop multiple clients connecting to the FTP
|
|
listener at the same time, and indeed some FTP clients do use multiple
|
|
connections, so this server can accept and create multiple cxt objects.
|
|
Each cxt object can also have a single DATA connection.
|
|
|
|
Note that FTP also exposes a number of really private properties (which
|
|
could be stores in local / upvals) as FTP properties for debug purposes.
|
|
|
|
Note that this version has now been updated to allow the main methods to
|
|
be optionally loaded lazily, and SPILT comments allow the source to be
|
|
preprocessed for loading as either components in the "fast" Cmodule or as
|
|
LC files in SPIFFS.
|
|
]]
|
|
--luacheck: read globals fast file net node tmr uart wifi FAST_ftp SPIFFS_ftp
|
|
|
|
local FTP, FTPindex = {client = {}}, nil
|
|
|
|
if FAST_ftp then
|
|
function FTPindex(_, name) return fast.load('ftp-'..name) end
|
|
elseif SPIFFS_ftp then
|
|
function FTPindex(_, name) return loadfile('ftp-'..name..'.lc') end
|
|
end
|
|
|
|
if FTPindex then return setmetatable(FTP,{__index=FTPindex}) end
|
|
|
|
function FTP.open(...) --[[SPLIT HERE ftp-open]]
|
|
--------------------------- Set up the FTP object ----------------------------
|
|
-- FTP has three static methods: open, createServer and close
|
|
------------------------------------------------------------------------------
|
|
|
|
-- optional wrapper around createServer() which also starts the wifi session
|
|
-- Lua: FTP:open(user, pass, ssid, pwd[, dbgFlag])
|
|
|
|
local this, user, pass, ssid, pwd, dbgFlag = ...
|
|
|
|
if ssid then
|
|
wifi.setmode(wifi.STATION, false)
|
|
wifi.sta.config { ssid = ssid, pwd = pwd, save = false }
|
|
end
|
|
tmr.create():alarm(500, tmr.ALARM_AUTO, function(t) -- this: FTP, user, pass, dbgFlag
|
|
if (wifi.sta.status() == wifi.STA_GOTIP) then
|
|
t:unregister()
|
|
print("Welcome to NodeMCU world", node.heap(), wifi.sta.getip())
|
|
return this:createServer(user, pass, dbgFlag)
|
|
else
|
|
uart.write(0,".")
|
|
end
|
|
end)
|
|
|
|
end --[[SPLIT IGNORE]]
|
|
function FTP.createServer(...) --[[SPLIT HERE ftp-createServer]]
|
|
-- Lua: FTP:createServer(user, pass[, dbgFlag])
|
|
local this, user, pass, dbgFlag = ...
|
|
local cnt = 0
|
|
this.user, this.pass, dbgFlag = user, pass, (dbgFlag and true or false)
|
|
|
|
this.debug = (not dbgFlag) and type -- executing type(...) is in effect a NOOP
|
|
or function(fmt, ...) -- upval: cnt
|
|
if (...) then fmt = fmt:format(...) end
|
|
print(node.heap(),fmt)
|
|
cnt = cnt + 1
|
|
if cnt % 10 then tmr.wdclr() end
|
|
end
|
|
|
|
this.server = net.createServer(net.TCP, 180)
|
|
_G.FTP = this
|
|
this.debug("Server created: (userdata) %s", tostring(this.server))
|
|
|
|
this.server:listen(21, function(sock) -- upval: this
|
|
-- since a server can have multiple connections, each connection
|
|
-- has its own CXN object (table) to store connection-wide globals.
|
|
local CXN; CXN = {
|
|
validUser = false,
|
|
cmdSocket = sock,
|
|
debug = this.debug,
|
|
FTP = this,
|
|
send = function(rec, cb) -- upval: CXN
|
|
CXN.debug("Sending: %s", rec)
|
|
return CXN.cmdSocket:send(rec.."\r\n", cb)
|
|
end, --- CXN. send()
|
|
close = function(socket) -- upval: CXN
|
|
CXN.debug("Closing CXN.cmdSocket=%s", tostring(CXN.cmdSocket))
|
|
for _,s in ipairs{'cmdSocket', 'dataServer', 'dataSocket'} do
|
|
CXN.debug("closing CXN.%s=%s", s, tostring(CXN[s]))
|
|
if type(CXN[s])=='userdata' then
|
|
pcall(socket.close, CXN[s])
|
|
CXN[s]= nil
|
|
end
|
|
end
|
|
CXN.FTP.client[socket] = nil
|
|
end -- CXN.close()
|
|
}
|
|
|
|
local function validateUser(socket, data) -- upval: CXN
|
|
-- validate the logon and if then switch to processing commands
|
|
CXN.debug("Authorising: %s", data)
|
|
local cmd, arg = data:match('([A-Za-z]+) *([^\r\n]*)')
|
|
local msg = "530 Not logged in, authorization required"
|
|
cmd = cmd:upper()
|
|
|
|
if cmd == 'USER' then
|
|
CXN.validUser = (arg == CXN.FTP.user)
|
|
msg = CXN.validUser and
|
|
"331 OK. Password required" or
|
|
"530 user not found"
|
|
|
|
elseif CXN.validUser and cmd == 'PASS' then
|
|
if arg == CXN.FTP.pass then
|
|
CXN.cwd = '/'
|
|
socket:on("receive", function(soc, rec) -- upval: CXN
|
|
assert(soc==CXN.cmdSocket)
|
|
CXN.FTP.processCommand(CXN, rec)
|
|
end) -- logged on so switch to command mode
|
|
msg = "230 Login successful. Username & password correct; proceed."
|
|
else
|
|
msg = "530 Try again"
|
|
end
|
|
|
|
elseif cmd == 'AUTH' then
|
|
msg = "500 AUTH not understood"
|
|
end
|
|
|
|
return CXN.send(msg)
|
|
end
|
|
|
|
local port,ip = sock:getpeer() -- luacheck: no unused
|
|
--cxt.debug("Connection accepted: (userdata) %s client %s:%u", tostring(sock), ip, port)
|
|
sock:on("receive", validateUser)
|
|
sock:on("disconnection", CXN.close)
|
|
this.client[sock]=CXN
|
|
|
|
CXN.send("220 FTP server ready");
|
|
end) -- this.server:listen()
|
|
end --[[SPLIT IGNORE]]
|
|
function FTP.close(...) --[[SPLIT HERE ftp-close]]
|
|
-- Lua: FTP:close()
|
|
|
|
local this = ...
|
|
|
|
-- this.client is a table of soc = cnx. The first (and usually only connection) is cleared
|
|
-- immediately and next() used to do a post chain so we only close one client per task
|
|
local function rollupClients(skt,cxt) -- upval: this, rollupClients
|
|
if skt then
|
|
this.debug("Client close: %s", tostring(skt))
|
|
cxt.close(skt)
|
|
this.client[skt] = nil
|
|
node.task.post(function() return rollupClients(next(this.client, skt)) end) -- upval: rollupClients, this, skt
|
|
else -- we have emptied the open socket table, so can now shut the server
|
|
this.debug("Server close: %s", tostring(this. server))
|
|
this.server:close()
|
|
this.server:__gc()
|
|
_G.FTP = nil
|
|
end
|
|
end
|
|
rollupClients(next(this.client))
|
|
package.loaded.ftpserver = nil
|
|
|
|
end --[[SPLIT IGNORE]]
|
|
function FTP.processCommand(...) --[[SPLIT HERE ftp-processCommand]]
|
|
----------------------------- Process Command --------------------------------
|
|
-- This splits the valid commands into one of three categories:
|
|
-- * bare commands (which take no arg)
|
|
-- * simple commands (which take) a single arg; and
|
|
-- * data commands which initiate data transfer to or from the client and
|
|
-- hence need to use CBs.
|
|
--
|
|
-- Find strings are used do this lookup and minimise long if chains.
|
|
------------------------------------------------------------------------------
|
|
|
|
local cxt, data = ...
|
|
|
|
cxt.debug("Command: %s", data)
|
|
data = data:gsub('[\r\n]+$', '') -- chomp trailing CRLF
|
|
local cmd, arg = data:match('([a-zA-Z]+) *(.*)')
|
|
cmd = cmd:upper()
|
|
local _cmd_ = '_'..cmd..'_'
|
|
if ('_CDUP_NOOP_PASV_PWD_QUIT_SYST_'):find(_cmd_) then
|
|
cxt.FTP.processBareCmds(cxt, cmd)
|
|
elseif ('_CWD_DELE_MODE_PORT_RNFR_RNTO_SIZE_TYPE_'):find(_cmd_) then
|
|
cxt.FTP.processSimpleCmds(cxt, cmd, arg)
|
|
elseif ('_LIST_NLST_RETR_STOR_'):find(_cmd_) then
|
|
cxt.FTP.processDataCmds(cxt, cmd, arg)
|
|
else
|
|
cxt.send("500 Unknown error")
|
|
end
|
|
|
|
end --[[SPLIT IGNORE]]
|
|
function FTP.processBareCmds(...) --[[SPLIT HERE ftp-processBareCmds]]
|
|
-------------------------- Process Bare Commands -----------------------------
|
|
|
|
local cxt, cmd = ...
|
|
|
|
|
|
local send = cxt.send
|
|
|
|
if cmd == 'CDUP' then
|
|
return send("250 OK. Current directory is "..cxt.cwd)
|
|
|
|
elseif cmd == 'NOOP' then
|
|
return send("200 OK")
|
|
|
|
elseif cmd == 'PASV' then
|
|
-- This FTP implementation ONLY supports PASV mode, and the passive port
|
|
-- listener is opened on receipt of the PASV command. If any data xfer
|
|
-- commands return an error if the PASV command hasn't been received.
|
|
-- Note the listener service is closed on receipt of the next PASV or
|
|
-- quit.
|
|
local ip, port, pphi, pplo, i1, i2, i3, i4, _
|
|
_,ip = cxt.cmdSocket:getaddr()
|
|
port = 2121
|
|
pplo = port % 256
|
|
pphi = (port-pplo)/256
|
|
i1,i2,i3,i4 = ip:match("(%d+).(%d+).(%d+).(%d+)")
|
|
cxt.FTP.dataServer(cxt, port)
|
|
return send(
|
|
('227 Entering Passive Mode(%d,%d,%d,%d,%d,%d)'):format(
|
|
i1,i2,i3,i4,pphi,pplo))
|
|
|
|
elseif cmd == 'PWD' then
|
|
return send('257 "/" is the current directory')
|
|
|
|
elseif cmd == 'QUIT' then
|
|
send("221 Goodbye", function() cxt.close(cxt.cmdSocket) end) -- upval: cxt
|
|
return
|
|
|
|
elseif cmd == 'SYST' then
|
|
-- return send("215 UNKNOWN")
|
|
return send("215 UNIX Type: L8") -- must be Unix so ls is parsed correctly
|
|
|
|
else
|
|
error('Oops. Missed '..cmd)
|
|
end
|
|
|
|
end --[[SPLIT IGNORE]]
|
|
function FTP.processSimpleCmds(...) --[[SPLIT HERE ftp-processSimpleCmds]]
|
|
|
|
------------------------- Process Simple Commands ----------------------------
|
|
|
|
local cxt, cmd, arg = ...
|
|
|
|
|
|
local send = cxt.send
|
|
|
|
if cmd == 'MODE' then
|
|
return send(arg == "S" and "200 S OK" or
|
|
"504 Only S(tream) is suported")
|
|
|
|
elseif cmd == 'PORT' then
|
|
cxt.FTP.dataServer(cxt,nil) -- clear down any PASV setting
|
|
return send("502 Active mode not supported. PORT not implemented")
|
|
|
|
elseif cmd == 'TYPE' then
|
|
if arg == "A" then
|
|
cxt.xferType = 0
|
|
return send("200 TYPE is now ASII")
|
|
elseif arg == "I" then
|
|
cxt.xferType = 1
|
|
return send("200 TYPE is now 8-bit binary")
|
|
else
|
|
return send("504 Unknown TYPE")
|
|
end
|
|
end
|
|
|
|
-- The remaining commands take a filename as an arg. Strip off leading / and ./
|
|
arg = arg:gsub('^%.?/',''):gsub('^%.?/','')
|
|
cxt.debug("Filename is %s",arg)
|
|
|
|
if cmd == 'CWD' then
|
|
if arg:match('^[%./]*$') then
|
|
return send("250 CWD command successful")
|
|
end
|
|
return send("550 "..arg..": No such file or directory")
|
|
|
|
elseif cmd == 'DELE' then
|
|
if file.exists(arg) then
|
|
file.remove(arg)
|
|
if not file.exists(arg) then return send("250 Deleted "..arg) end
|
|
end
|
|
return send("550 Requested action not taken")
|
|
|
|
elseif cmd == 'RNFR' then
|
|
cxt.from = arg
|
|
send("350 RNFR accepted")
|
|
return
|
|
|
|
elseif cmd == 'RNTO' then
|
|
local status = cxt.from and file.rename(cxt.from, arg)
|
|
cxt.debug("rename('%s','%s')=%s", tostring(cxt.from), tostring(arg), tostring(status))
|
|
cxt.from = nil
|
|
return send(status and "250 File renamed" or
|
|
"550 Requested action not taken")
|
|
elseif cmd == "SIZE" then
|
|
local st = file.stat(arg)
|
|
return send(st and ("213 "..st.size) or
|
|
"550 Could not get file size.")
|
|
|
|
else
|
|
error('Oops. Missed '..cmd)
|
|
end
|
|
|
|
end --[[SPLIT IGNORE]]
|
|
function FTP.processDataCmds(...) --[[SPLIT HERE ftp-processDataCmds]]
|
|
|
|
-------------------------- Process Data Commands -----------------------------
|
|
|
|
local cxt, cmd, arg = ...
|
|
|
|
|
|
local send, FTP = cxt.send, cxt.FTP -- luacheck: ignore FTP
|
|
|
|
-- The data commands are only accepted if a PORT command is in scope
|
|
if FTP.dataServer == nil and cxt.dataSocket == nil then
|
|
return send("502 Active mode not supported. "..cmd.." not implemented")
|
|
end
|
|
|
|
cxt.getData, cxt.setData = nil, nil
|
|
|
|
arg = arg:gsub('^%.?/',''):gsub('^%.?/','')
|
|
|
|
if cmd == "LIST" or cmd == "NLST" then
|
|
-- There are
|
|
local fileSize, nameList, pattern = file.list(), {}, '.'
|
|
|
|
arg = arg:gsub('^-[a-z]* *', '') -- ignore any Unix style command parameters
|
|
arg = arg:gsub('^/','') -- ignore any leading /
|
|
|
|
if #arg > 0 and arg ~= '.' then -- replace "*" by [^/%.]* that is any string not including / or .
|
|
pattern = arg:gsub('*','[^/%%.]*')
|
|
end
|
|
|
|
for k, _ in pairs(fileSize) do
|
|
if k:match(pattern) then
|
|
nameList[#nameList+1] = k
|
|
else
|
|
fileSize[k] = nil
|
|
end
|
|
end
|
|
table.sort(nameList)
|
|
|
|
function cxt.getData(c) -- upval: cmd, fileSize, nameList
|
|
local list, user = {}, c.FTP.user
|
|
for i = 1,10 do -- luacheck: no unused
|
|
if #nameList == 0 then break end
|
|
local f = table.remove(nameList, 1)
|
|
list[#list+1] = (cmd == "LIST") and
|
|
("-rw-r--r-- 1 %s %s %6u Jan 1 00:00 %s\r\n"):format(user, user, fileSize[f], f) or
|
|
(f.."\r\n")
|
|
end
|
|
return table.concat(list)
|
|
end
|
|
|
|
elseif cmd == "RETR" then
|
|
local f = file.open(arg, "r")
|
|
if f then -- define a getter to read the file
|
|
function cxt.getData(c) -- luacheck: ignore c -- upval: f
|
|
local buf = f:read(1024)
|
|
if not buf then f:close(); f = nil; end
|
|
return buf
|
|
end -- cxt.getData()
|
|
end
|
|
|
|
elseif cmd == "STOR" then
|
|
local f = file.open(arg, "w")
|
|
if f then -- define a setter to write the file
|
|
function cxt.setData(c, rec) -- luacheck: ignore c -- upval: f (, arg)
|
|
cxt.debug("writing %u bytes to %s", #rec, arg)
|
|
return f:write(rec)
|
|
end -- cxt.saveData(rec)
|
|
function cxt.fileClose(c) -- luacheck: ignore c -- upval: f (,arg)
|
|
cxt.debug("closing %s", arg)
|
|
f:close(); f = nil
|
|
end -- cxt.close()
|
|
end
|
|
|
|
end
|
|
|
|
send((cxt.getData or cxt.setData) and "150 Accepted data connection" or
|
|
"451 Can't open/create "..arg)
|
|
if cxt.getData and cxt.dataSocket then
|
|
cxt.debug ("poking sender to initiate first xfer")
|
|
node.task.post(function() cxt.sender(cxt.dataSocket) end) -- upval: cxt
|
|
end
|
|
|
|
end --[[SPLIT IGNORE]]
|
|
function FTP.dataServer(...) --[[SPLIT HERE ftp-dataServer]]
|
|
----------------------------- Data Port Routines -----------------------------
|
|
-- These are used to manage the data transfer over the data port. This is
|
|
-- set up lazily either by a PASV or by the first LIST NLST RETR or STOR
|
|
-- command that uses it. These also provide a sendData / receiveData cb to
|
|
-- handle the actual xfer. Also note that the sending process can be primed in
|
|
--
|
|
---------------- Open a new data server and port ---------------------------
|
|
|
|
local cxt, n = ...
|
|
|
|
local dataSvr = cxt.dataServer
|
|
if dataSvr then pcall(dataSvr.close, dataSrv) end -- luacheck: ignore -- close any existing listener
|
|
if n then
|
|
-- Open a new listener if needed. Note that this is only used to establish
|
|
-- a single connection, so ftpDataOpen closes the server socket
|
|
dataSvr = net.createServer(net.TCP, 300)
|
|
cxt.dataServer = dataSvr
|
|
dataSvr:listen(n, function(sock) -- upval: cxt
|
|
cxt.FTP.ftpDataOpen(cxt,sock)
|
|
end)
|
|
cxt.debug("Listening on Data port %u, server %s",n, tostring(cxt.dataServer))
|
|
else
|
|
cxt.dataServer = nil
|
|
cxt.debug("Stopped listening on Data port",n)
|
|
end
|
|
|
|
end --[[SPLIT IGNORE]]
|
|
function FTP.ftpDataOpen(...) --[[SPLIT HERE ftp-ftpDataOpen]]
|
|
----------------------- Connection on FTP data port ------------------------
|
|
|
|
local cxt, dataSocket = ...
|
|
|
|
local sport,sip = dataSocket:getaddr()
|
|
local cport,cip = dataSocket:getpeer()
|
|
cxt.debug("Opened data socket %s from %s:%u to %s:%u", tostring(dataSocket),sip,sport,cip,cport )
|
|
cxt.dataSocket = dataSocket
|
|
|
|
cxt.dataServer:close()
|
|
cxt.dataServer = nil
|
|
|
|
function cxt.cleardown(cxt, skt, cdtype) --luacheck: ignore cxt -- shadowing
|
|
-- luacheck: ignore cdtype which
|
|
cdtype = cdtype==1 and "disconnection" or "reconnection"
|
|
local which = cxt.setData and "setData" or (cxt.getData and cxt.getData or "neither")
|
|
cxt.debug("Cleardown entered from %s with %s", cdtype, which)
|
|
if cxt.setData then
|
|
cxt:fileClose()
|
|
cxt.setData = nil
|
|
cxt.send("226 Transfer complete.")
|
|
else
|
|
cxt.getData, cxt.sender = nil, nil
|
|
end
|
|
cxt.debug("Clearing down data socket %s", tostring(skt))
|
|
node.task.post(function() -- upval: cxt, skt
|
|
pcall(skt.close, skt); skt=nil
|
|
cxt.dataSocket = nil
|
|
end)
|
|
end
|
|
|
|
local on_hold = false
|
|
|
|
dataSocket:on("receive", function(skt, rec) -- upval: cxt, on_hold
|
|
|
|
local rectype = cxt.setData and "setData" or (cxt.getData and cxt.getData or "neither")
|
|
cxt.debug("Received %u data bytes with %s", #rec, rectype)
|
|
|
|
if not cxt.setData then return end
|
|
|
|
if not on_hold then
|
|
-- Cludge to stop the client flooding the ESP SPIFFS on an upload of a
|
|
-- large file. As soon as a record arrives assert a flow control hold.
|
|
-- This can take up to 5 packets to come into effect at which point the
|
|
-- low priority unhold task is executed releasing the flow again.
|
|
cxt.debug("Issuing hold on data socket %s", tostring(skt))
|
|
skt:hold(); on_hold = true
|
|
node.task.post(node.task.LOW_PRIORITY,
|
|
function() -- upval: skt, on_hold
|
|
cxt.debug("Issuing unhold on data socket %s", tostring(skt))
|
|
pcall(skt.unhold, skt); on_hold = false
|
|
end)
|
|
end
|
|
|
|
if not cxt:setData(rec) then
|
|
cxt.debug("Error writing to SPIFFS")
|
|
cxt:fileClose()
|
|
cxt.setData = nil
|
|
cxt.send("552 Upload aborted. Exceeded storage allocation")
|
|
end
|
|
end)
|
|
|
|
function cxt.sender(skt) -- upval: cxt
|
|
cxt.debug ("entering sender")
|
|
if not cxt.getData then return end
|
|
skt = skt or cxt.dataSocket
|
|
local rec = cxt:getData()
|
|
if rec and #rec > 0 then
|
|
cxt.debug("Sending %u data bytes", #rec)
|
|
skt:send(rec)
|
|
else
|
|
cxt.debug("Send of data completed")
|
|
skt:close()
|
|
cxt.send("226 Transfer complete.")
|
|
cxt.getData, cxt.dataSocket = nil, nil
|
|
end
|
|
end
|
|
|
|
dataSocket:on("sent", cxt.sender)
|
|
dataSocket:on("disconnection", function(skt) return cxt:cleardown(skt,1) end) -- upval: cxt
|
|
dataSocket:on("reconnection", function(skt) return cxt:cleardown(skt,2) end) -- upval: cxt
|
|
|
|
-- if we are sending to client then kick off the first send
|
|
if cxt.getData then cxt.sender(cxt.dataSocket) end
|
|
|
|
end --[[SPLIT HERE]]
|
|
return FTP --[[SPLIT IGNORE]]
|