-- pushfs_server.lua
-- CC:Tweaked turtle/computer daemon to receive files and run remote commands.
-- Ops: ping | put | begin/chunk/end | exec
-- Protocol: "pushfs-v1"
--------------------------
-- Config
--------------------------
local PROTOCOL = "pushfs-v1"
local SHARED_SECRET = nil -- set to a string to require a token
local MAX_IDLE_SEC = 120
local MAX_RETURN_BYTES = 64*1024 -- cap returned output
-- Lua 5.1/5.2 compat
local unpack = table.unpack or unpack
--------------------------
-- Utilities
--------------------------
local function openModems()
local ok = false
for _, s in ipairs(rs.getSides()) do
if peripheral.getType(s) == "modem" then
if not rednet.isOpen(s) then rednet.open(s) end
ok = true
end
end
if not ok then error("No modem found. Attach a wireless modem.") end
end
local function adler32(str)
local MOD = 65521
local a,b = 1,0
for i=1,#str do
a = (a + string.byte(str,i)) % MOD
b = (b + a) % MOD
end
return string.format("%08x", bit32.bor(bit32.lshift(b,16), a))
end
local function readAll(path)
if not fs.exists(path) or fs.isDir(path) then return "" end
local h = fs.open(path, "rb"); local d = h.readAll() or ""; h.close(); return d
end
local function parentDir(path)
local p = path:gsub("/+$","")
local i = p:match("^(.*)/[^/]+$")
return i or ""
end
local function sanitizePath(p)
if p:sub(1,1) ~= "/" then p = "/"..p end
p = p:gsub("//+","/")
if p:find("%.%.") then return nil, "Path traversal not allowed" end
if p:sub(1,5) == "/rom/" or p == "/rom" then return nil, "ROM is read-only" end
return p
end
local function matchesTarget(msg, myId, myLabel)
local t = msg.target
if t == "all" then return true end
if type(t) == "table" then
if t.id and t.id == myId then return true end
if t.label and myLabel and t.label == myLabel then return true end
end
return false
end
local function ack(toId, txid, ok, op, note, extra)
local m = { proto=PROTOCOL, txid=txid, ok=ok, op=op, note=note }
if extra then for k,v in pairs(extra) do m[k]=v end end
rednet.send(toId, m, PROTOCOL)
end
--------------------------
-- File transfers
--------------------------
local xfers = {} -- txid -> { tmp, dst, size, checksum, received, h, t_start }
local function cleanupTx(tx)
if tx.h then pcall(function() tx.h.close() end) end
if tx.tmp and fs.exists(tx.tmp) then pcall(function() fs.delete(tx.tmp) end) end
end
local function handle_begin(sender, msg)
if SHARED_SECRET and msg.token ~= SHARED_SECRET then
return ack(sender, msg.txid, false, "begin", "auth failed")
end
if type(msg.fname) ~= "string" or type(msg.size) ~= "number" then
return ack(sender, msg.txid, false, "begin", "bad params")
end
local dst, err = sanitizePath(msg.fname)
if not dst then return ack(sender, msg.txid, false, "begin", err) end
local tmp = dst..".push.tmp"
local pdir = parentDir(dst)
if pdir ~= "" and not fs.exists(pdir) then fs.makeDir(pdir) end
if fs.exists(tmp) then fs.delete(tmp) end
local h, e = fs.open(tmp, "wb")
if not h then return ack(sender, msg.txid, false, "begin", "open tmp failed: "..tostring(e)) end
xfers[msg.txid] = {
tmp = tmp, dst = dst, size = msg.size,
checksum = msg.checksum, received = 0, h = h, t_start = os.clock()
}
return ack(sender, msg.txid, true, "begin", "ready")
end
local function handle_chunk(sender, msg)
local tx = xfers[msg.txid]
if not tx then return ack(sender, msg.txid, false, "chunk", "unknown txid") end
if not msg.data or type(msg.data) ~= "string" then
return ack(sender, msg.txid, false, "chunk", "no data")
end
tx.h.write(msg.data)
tx.received = tx.received + #msg.data
tx.t_start = os.clock()
return ack(sender, msg.txid, true, "chunk", "ok", {received=tx.received})
end
local function handle_end(sender, msg)
local tx = xfers[msg.txid]
if not tx then return ack(sender, msg.txid, false, "end", "unknown txid") end
tx.h.close(); tx.h = nil
if tx.size and tx.received ~= tx.size then
cleanupTx(tx); xfers[msg.txid]=nil
return ack(sender, msg.txid, false, "end", ("size mismatch %d!=%d"):format(tx.received, tx.size))
end
if tx.checksum then
local data = readAll(tx.tmp)
local sum = adler32(data)
if sum ~= tx.checksum then
cleanupTx(tx); xfers[msg.txid]=nil
return ack(sender, msg.txid, false, "end", "checksum mismatch", {have=sum, want=tx.checksum})
end
end
if fs.exists(tx.dst) and fs.isDir(tx.dst) then
cleanupTx(tx); xfers[msg.txid]=nil
return ack(sender, msg.txid, false, "end", "destination is a directory")
end
if fs.exists(tx.dst) then fs.delete(tx.dst) end
fs.move(tx.tmp, tx.dst)
xfers[msg.txid]=nil
return ack(sender, msg.txid, true, "end", "written", {path=tx.dst, bytes=tx.received})
end
local function handle_put(sender, msg)
if SHARED_SECRET and msg.token ~= SHARED_SECRET then
return ack(sender, msg.txid, false, "put", "auth failed")
end
if type(msg.fname) ~= "string" or type(msg.data) ~= "string" then
return ack(sender, msg.txid, false, "put", "bad params")
end
local dst, err = sanitizePath(msg.fname)
if not dst then return ack(sender, msg.txid, false, "put", err) end
local pdir = parentDir(dst)
if pdir ~= "" and not fs.exists(pdir) then fs.makeDir(pdir) end
local tmp = dst..".push.tmp"
if fs.exists(tmp) then fs.delete(tmp) end
local h, e = fs.open(tmp, "wb")
if not h then return ack(sender, msg.txid, false, "put", "open tmp failed: "..tostring(e)) end
h.write(msg.data); h.close()
if msg.checksum then
local have = adler32(readAll(tmp))
if have ~= msg.checksum then
fs.delete(tmp)
return ack(sender, msg.txid, false, "put", "checksum mismatch", {have=have, want=msg.checksum})
end
end
if fs.exists(dst) then fs.delete(dst) end
fs.move(tmp, dst)
return ack(sender, msg.txid, true, "put", "written", {path=dst, bytes=#msg.data})
end
--------------------------
-- Exec
--------------------------
-- tiny in-memory terminal to capture output
local function makeBufferTerm(width, height)
local buf = {}
local x,y,tx,bc = 1,1,colors.white,colors.black
local w,h = width or 80, height or 25
for i=1,h do buf[i] = string.rep(" ", w) end
local function clamp()
if x < 1 then x = 1 elseif x > w then x = w end
if y < 1 then y = 1 elseif y > h then y = h end
end
local t = {}
function t.write(s)
s = tostring(s)
local line = buf[y]
local left = line:sub(1, x-1)
local right = line:sub(x + #s)
buf[y] = left .. s .. right
x = x + #s
clamp()
end
function t.blit(s, tc, bc_) t.write(s) end
function t.clear() for i=1,h do buf[i] = string.rep(" ", w) end x,y=1,1 end
function t.clearLine() buf[y] = string.rep(" ", w); x=1 end
function t.getCursorPos() return x,y end
function t.setCursorPos(nx,ny) x,y=nx,ny; clamp() end
function t.setCursorBlink(_) end
function t.getSize() return w,h end
function t.scroll(n)
n = n or 1
if n > 0 then for _=1,n do table.remove(buf,1); table.insert(buf, string.rep(" ", w)) end
elseif n < 0 then for _=1,-n do table.remove(buf,#buf); table.insert(buf,1, string.rep(" ", w)) end
end
end
function t.isColor() return term.isColor and term.isColor() or true end
function t.setTextColor(c) tx=c end
function t.setBackgroundColor(c) bc=c end
function t.concat() return table.concat(buf, "\n") end
return t
end
local function split_cmdline(line)
local out, i, n = {}, 1, #line
while i <= n do
while i<=n and line:sub(i,i):match("%s") do i=i+1 end
if i>n then break end
local tok = {}
local quote = nil
while i<=n do
local ch = line:sub(i,i)
if quote then
if ch == quote then quote=nil; i=i+1; break
else tok[#tok+1]=ch; i=i+1 end
else
if ch == '"' or ch == "'" then quote = ch; i=i+1
elseif ch:match("%s") then i=i+1; break
else tok[#tok+1]=ch; i=i+1 end
end
end
out[#out+1] = table.concat(tok)
end
return out
end
local function run_command_capture(line, cwd, timeout_s)
local w,h = term.getSize()
local cap = makeBufferTerm(w,h)
local oldTerm = term.redirect(cap)
local oldDir = shell.dir()
if cwd and type(cwd)=="string" and fs.isDir(cwd) then shell.setDir(cwd) end
local ok, err
local function runner()
local args = split_cmdline(line)
if #args == 0 then return true end
ok = shell.run(unpack(args))
if not ok then err = "command failed" end
end
local done = false
local function wrap()
local s, e = pcall(runner)
if not s then ok=false; err=tostring(e) end
done = true
end
if timeout_s and timeout_s > 0 then
local tid = os.startTimer(timeout_s)
parallel.waitForAny(function() wrap() end, function()
while true do local ev,id=os.pullEvent("timer"); if id==tid then return end end
end)
if not done then ok=false; err="timeout" end
else
wrap()
end
term.redirect(oldTerm)
shell.setDir(oldDir)
local out = cap.concat()
if #out > MAX_RETURN_BYTES then
out = out:sub(1, MAX_RETURN_BYTES) .. "\n[output truncated]"
end
return ok == true, out, err
end
local function handle_exec(sender, msg)
-- msg: {op="exec", txid, line="<cmd>", capture=true/false, timeout=sec, cwd="/", token=...}
if SHARED_SECRET and msg.token ~= SHARED_SECRET then
return ack(sender, msg.txid, false, "exec", "auth failed")
end
if type(msg.line) ~= "string" or #msg.line == 0 then
return ack(sender, msg.txid, false, "exec", "missing line")
end
if msg.capture then
local ok, out, err = run_command_capture(msg.line, msg.cwd, msg.timeout)
return ack(sender, msg.txid, ok, "exec", ok and "ok" or (err or "error"), {output=out})
else
-- background only when tabs exist; otherwise reject so client can use capture
local cmdline = msg.line
if multishell and multishell.launch then
multishell.launch({}, "/rom/programs/shell", "-c", cmdline)
return ack(sender, msg.txid, true, "exec", "started")
elseif shell.openTab then
shell.openTab("/rom/programs/shell", "-c", cmdline)
return ack(sender, msg.txid, true, "exec", "started")
else
return ack(sender, msg.txid, false, "exec", "no background support on this device; use capture=true")
end
end
end
local function handle_ping(sender, msg, myId, myLabel)
return ack(sender, msg.txid, true, "ping", "pong", {id=myId, label=myLabel or "", time=os.epoch("utc")})
end
--------------------------
-- Reaper
--------------------------
local function reapStale()
local now = os.clock()
for txid, tx in pairs(xfers) do
if now - (tx.t_start or now) > MAX_IDLE_SEC then
cleanupTx(tx)
xfers[txid] = nil
end
end
end
--------------------------
-- Main
--------------------------
local function main()
openModems()
local myId = os.getComputerID()
local myLabel = os.getComputerLabel()
rednet.host(PROTOCOL, (myLabel or ("node-"..tostring(myId))))
print(("pushfs_server: id=%d label=%s"):format(myId, tostring(myLabel)))
print("Waiting...")
while true do
local ev = { os.pullEvent() }
if ev[1] == "rednet_message" then
local sender, message, proto = ev[2], ev[3], ev[4]
if proto == PROTOCOL and type(message) == "table" and matchesTarget(message, myId, myLabel) then
local op = message.op
if op == "begin" then handle_begin(sender, message)
elseif op == "chunk" then handle_chunk(sender, message)
elseif op == "end" then handle_end(sender, message)
elseif op == "put" then handle_put(sender, message)
elseif op == "exec" then handle_exec(sender, message)
elseif op == "ping" then handle_ping(sender, message, myId, myLabel)
else ack(sender, message.txid, false, op or "?", "unknown op") end
end
elseif ev[1] == "timer" then
reapStale()
end
os.startTimer(5)
end
end
local ok, err = pcall(main)
if not ok then
print("Error: "..tostring(err))
for _, tx in pairs(xfers) do cleanupTx(tx) end
end
Comments