· 3 months ago · Jun 22, 2025, 05:55 AM
1-- MIT License
2--
3-- Copyright (c) 2021 JackMacWindows
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
23local rawterm = require "rawterm" -- https://gist.github.com/MCJack123/50b211c55ceca4376e51d33435026006
24local hasECC, ecc -- https://pastebin.com/ZGJGBJdg (comment out `os.pullEvent`s)
25local hasRedrun, redrun -- https://gist.github.com/MCJack123/473475f07b980d57dd2bd818026c97e8
26
27local localEvents = {key = true, key_up = true, char = true, mouse_click = true, mouse_up = true, mouse_drag = true, mouse_scroll = true, mouse_move = true, term_resize = true, paste = true}
28local serverRunning = false
29local width, height = term.getSize()
30
31local function randomString()
32 local str = ""
33 for i = 1, 16 do str = str .. string.char(math.random(32, 127)) end
34 return str
35end
36
37local function singleserver(delegate, func, ...)
38 local server = rawterm.server(delegate, width, height, 0, "Remote Shell")
39 delegate.server = server
40 local coro = coroutine.create(func)
41 local oldterm = term.redirect(server)
42 local ok, filter = coroutine.resume(coro, ...)
43 term.redirect(oldterm)
44 server.setVisible(false)
45 local lastRender = os.epoch "utc"
46 while ok and coroutine.status(coro) == "suspended" and not delegate.closed do
47 local ev = table.pack(server.pullEvent(filter, true))
48 oldterm = term.redirect(server)
49 ok, filter = coroutine.resume(coro, table.unpack(ev, 1, ev.n))
50 term.redirect(oldterm)
51 if os.epoch "utc" - lastRender >= 50 then
52 server.setVisible(true)
53 server.setVisible(false)
54 lastRender = os.epoch "utc"
55 end
56 end
57 if not ok then printError(filter) end
58 server.close()
59 if coroutine.status(coro) == "suspended" then
60 oldterm = term.redirect(server)
61 filter = coroutine.resume(coro, "terminate")
62 term.redirect(oldterm)
63 end
64end
65
66local delegate_mt = {}
67delegate_mt.__index = delegate_mt
68function delegate_mt:send(data)
69 if self.closed then return end
70 if self.key then data = string.char(table.unpack(ecc.encrypt(randomString() .. data, self.key))) end
71 self.modem.transmit(self.port, self.port, {id = os.computerID(), data = data})
72end
73function delegate_mt:receive()
74 if self.closed then return nil end
75 while true do
76 local ev, side, channel, reply, message = os.pullEventRaw("modem_message")
77 if ev == "modem_message" and channel == self.port and type(message) == "table" and message.id == self.id then
78 message = message.data
79 if self.key then
80 message = string.char(table.unpack(ecc.decrypt(message, self.key)))
81 --[[ argh, decrypt yields and that will break this, so we have to run it in a coroutine!
82 local coro = coroutine.create(ecc.decrypt)
83 local ok, a
84 while coroutine.status(coro) == "suspended" do ok, a = coroutine.resume(coro, message, self.key) end
85 if not ok then printError(message) return end
86 message = string.char(table.unpack(a))
87 ]]
88 if #message > 16 and not self.nonces[message:sub(1, 16)] then
89 self.nonces[message:sub(1, 16)] = true
90 self.port = reply
91 return message:sub(17)
92 end
93 else
94 self.port = reply
95 return message
96 end
97 end
98 end
99end
100function delegate_mt:close()
101 if self.closed then return end
102 if not self.silent then print("> Closed connection on port " .. self.port) end
103 self.modem.close(self.port)
104 self.key = nil
105 self.nonces = nil
106 self.closed = true
107end
108
109local function makeDelegate(modem, port, key, id, silent)
110 modem.open(port)
111 return setmetatable({
112 modem = modem,
113 port = port,
114 key = key,
115 id = id,
116 silent = silent,
117 closed = false,
118 nonces = key and {}
119 }, delegate_mt)
120end
121
122local function serve(password, secure, modem, program, url, background)
123 if secure and not hasECC then error("Secure mode requires the ECC library to function.", 2)
124 elseif password and not secure then
125 term.setTextColor(colors.yellow)
126 print("Warning: A password was set, but secure mode is disabled. Password will be sent in plaintext.")
127 term.setTextColor(colors.white)
128 end
129 modem = modem or peripheral.find("modem")
130 if not modem then error("Please attach a modem.", 2) end
131 modem.open(5731)
132 local priv, pub
133 if secure then
134 priv, pub = ecc.keypair(ecc.random.random())
135 if password then password = ecc.sha256.digest(password):toHex() end
136 end
137 print("Server is now listening for connections.")
138 local threads = {}
139 local usedChallenges = {}
140 serverRunning = true
141 while serverRunning do
142 local ev = table.pack(coroutine.yield())
143 if ev[1] == "modem_message" and ev[3] == 5731 and type(ev[5]) == "table" and ev[5].server == os.computerID() then
144 if not ev[5].id then
145 modem.transmit(5731, 5731, {server = os.computerID(), status = "Missing ID"})
146 elseif secure and (not ev[5].key or not ev[5].challenge) then
147 modem.transmit(5731, 5731, {server = os.computerID(), id = ev[5].id, status = "Secure connection required", key = pub, challenge = randomString()})
148 elseif secure and (not ev[5].response or string.char(table.unpack(ecc.decrypt(ev[5].response, ecc.exchange(priv, ev[5].key)) or {})) ~= ev[5].challenge) then
149 modem.transmit(5731, 5731, {server = os.computerID(), id = ev[5].id, status = "Challenge failed", key = pub, challenge = randomString()})
150 elseif password and not ev[5].password then
151 modem.transmit(5731, 5731, {server = os.computerID(), id = ev[5].id, status = "Password required"})
152 else
153 local ok = true
154 local key
155 if secure then key = ecc.exchange(priv, ev[5].key) end
156 if password then
157 if secure then ok = not usedChallenges[ev[5].challenge] and string.char(table.unpack(ecc.decrypt(ev[5].password, key))) == password .. ev[5].challenge
158 else ok = ev[5].password == password end
159 end
160 if ok then
161 if secure then usedChallenges[ev[5].challenge] = true end
162 local port = math.random(1000, 65500)
163 while modem.isOpen(port) do port = math.random(1000, 65500) end
164 if not background then print("> New connection from ID " .. ev[5].id .. " on port " .. port) end
165 modem.transmit(5731, port, {server = os.computerID(), id = ev[5].id, status = "Opening connection"})
166 local coro = coroutine.create(singleserver)
167 local delegate = makeDelegate(modem, port, key, ev[5].id, background)
168 local ok, filter
169 if background then
170 if program then program = program:gsub("^%S+", shell.resolveProgram) end
171 ok, filter = coroutine.resume(coro, delegate, os.run, setmetatable({}, {__index = _G}), program or "rom/programs/shell.lua")
172 else ok, filter = coroutine.resume(coro, delegate, shell.run, program or "shell") end
173 if ok then threads[#threads+1] = {delegate = delegate, coro = coro, filter = filter}
174 else printError(filter) end
175 else
176 modem.transmit(5731, 5731, {server = os.computerID(), id = ev[5].id, status = "Password incorrect"})
177 end
178 end
179 elseif ev[1] == "terminate" then serverRunning = false
180 else
181 local ok
182 local delete = {}
183 for i,v in pairs(threads) do
184 if (v.filter == nil or v.filter == ev[1]) and not localEvents[ev[1]] then
185 ok, v.filter = coroutine.resume(v.coro, table.unpack(ev, 1, ev.n))
186 if not ok or coroutine.status(v.coro) ~= "suspended" then
187 if not ok then printError(v.filter) end
188 delete[#delete+1] = i
189 end
190 end
191 end
192 for _,v in ipairs(delete) do threads[v] = nil end
193 end
194 end
195 for _,v in pairs(threads) do
196 if coroutine.status(v.coro) == "suspended" then coroutine.resume(v.coro, "terminate") end
197 v.delegate.server.close()
198 end
199 print("Server closed.")
200end
201
202local function recv(id)
203 local tm = os.startTimer(5)
204 while true do
205 local ev = table.pack(os.pullEvent())
206 if ev[1] == "modem_message" and ev[3] == 5731 and type(ev[5]) == "table" and ev[5].server == id then return ev[5], ev[4]
207 elseif ev[1] == "timer" and ev[2] == tm then return nil end
208 end
209end
210
211local function connect(id, modem, win)
212 if not tonumber(id) then
213 if not http.checkURL(id:gsub("wss?://", "http://")) then error("ID argument must be a number or URL", 2) end
214 local delegate = rawterm.wsDelegate(id)
215 return rawterm.client(delegate, 0, win), delegate
216 end
217 id = tonumber(id)
218 modem = modem or peripheral.find("modem")
219 if not modem then error("Please attach a modem.", 2) end
220 modem.open(5731)
221 local req = {server = id, id = os.computerID()}
222 local key, res, port
223 while true do
224 modem.transmit(5731, 5731, req)
225 res, port = recv(id)
226 if not res then error("Connection failed: Timeout") end
227 if res.status == "Secure connection required" then
228 if not hasECC then hasECC, ecc = pcall(require, "ecc") end
229 if not hasECC then error("Connection failed: Server requires secure connection, but ECC library is not installed.", 2) end
230 local priv, pub = ecc.keypair(ecc.random.random())
231 key = ecc.exchange(priv, res.key)
232 req.key = pub
233 req.challenge = res.challenge
234 req.response = string.char(table.unpack(ecc.encrypt(res.challenge, key)))
235 elseif res.status == "Password required" then
236 if not key then print("Warning: This connection is not secure. Your password will be sent unencrypted.") end
237 write("Password: ")
238 req.password = read("\7")
239 if key then req.password = string.char(table.unpack(ecc.encrypt(ecc.sha256.digest(req.password):toHex() .. req.challenge, key))) end
240 elseif res.status == "Opening connection" then break
241 else error("Connection failed: " .. res.status, 2) end
242 end
243 local delegate = makeDelegate(modem, port, key, id, true)
244 return rawterm.client(delegate, 0, win), delegate
245end
246
247local args = {...}
248
249if args[1] == "serve" or args[1] == "host" then
250 local background = false
251 local program = nil
252 local modem = nil
253 local password = nil
254 local secure = false
255 local url = nil
256 local nextarg = nil
257 for _, arg in ipairs(args) do
258 if nextarg then
259 if nextarg == 1 then program = arg
260 elseif nextarg == 2 then modem = arg
261 elseif nextarg == 3 then password = arg
262 elseif nextarg == 4 then url = arg
263 elseif nextarg == 5 then
264 local w, h = arg:match("^(%d+)x(%d+)$")
265 if not w then error("Invalid argument for -r") end
266 width, height = tonumber(w), tonumber(h)
267 end
268 nextarg = nil
269 elseif arg == "-b" then
270 hasRedrun, redrun = pcall(require, "redrun")
271 background = true
272 elseif arg == "-s" then
273 hasECC, ecc = pcall(require, "ecc")
274 secure = true
275 elseif arg == "-c" then nextarg = 1
276 elseif arg == "-m" then nextarg = 2
277 elseif arg == "-p" then nextarg = 3
278 elseif arg == "-r" then nextarg = 5
279 elseif arg == "-w" then nextarg = 4 end
280 end
281
282 if modem then
283 if peripheral.getType(modem) ~= "modem" then error("Peripheral on selected side is not a modem.") end
284 modem = peripheral.wrap(modem)
285 end
286 if background then
287 if not hasRedrun then error("Background task running requires the RedRun library.") end
288 if url then
289 redrun.start(function() return singleserver(rawterm.wsDelegate(url, {["X-Rawterm-Is-Server"] = "Yes"}), os.run, setmetatable({}, {__index = _G}), program or "rom/programs/shell.lua") end, "rawshell_server")
290 else
291 redrun.start(function() return serve(password, secure, modem, program, url, true) end, "rawshell_server")
292 while not serverRunning do coroutine.yield() end
293 end
294 elseif url then singleserver(rawterm.wsDelegate(url, {["X-Rawterm-Is-Server"] = "Yes"}), shell.run, program or "shell")
295 else serve(password, secure, modem, program, url, false) end
296elseif args[1] == "connect" and args[2] then
297 local modem
298 if args[3] then
299 if peripheral.getType(args[3]) ~= "modem" then error("Peripheral on selected side is not a modem.") end
300 modem = peripheral.wrap(args[3])
301 end
302 local handle = connect(args[2], modem, term.current())
303 local ok, err = pcall(handle.run)
304 if term.current().setVisible then term.current().setVisible(true) end
305 handle.close()
306 term.setBackgroundColor(colors.black)
307 term.setTextColor(colors.white)
308 term.clear()
309 term.setCursorPos(1, 1)
310 term.setCursorBlink(true)
311 if not ok then error(err, 2) end
312elseif args[1] == "get" and args[2] and args[3] then
313 local modem
314 if args[5] then
315 if peripheral.getType(args[5]) ~= "modem" then error("Peripheral on selected side is not a modem.") end
316 modem = peripheral.wrap(args[5])
317 end
318 local handle, delegate = connect(args[2], modem, nil)
319 parallel.waitForAny(
320 function() while not handle.fs do handle.update(delegate:receive()) end end,
321 function() sleep(2) end)
322 if not handle.fs then error("Connection failed: Server does not support filesystem transfers") end
323 local infile, err = handle.fs.open(args[3], "rb")
324 if not infile then error("Could not open remote file: " .. (err or "Unknown error")) end
325 local outfile, err = fs.open(args[4] or shell.resolve(fs.getName(args[3])), "wb")
326 if not outfile then
327 infile.close()
328 error("Could not open local file: " .. (err or "Unknown error"))
329 end
330 outfile.write(infile.readAll())
331 infile.close()
332 outfile.close()
333 handle.close()
334 print("Downloaded file as " .. (args[4] or shell.resolve(fs.getName(args[3]))))
335elseif args[1] == "put" and args[2] and args[3] and args[4] then
336 local modem
337 if args[5] then
338 if peripheral.getType(args[5]) ~= "modem" then error("Peripheral on selected side is not a modem.") end
339 modem = peripheral.wrap(args[5])
340 end
341 local handle, delegate = connect(args[2], modem, nil)
342 parallel.waitForAny(
343 function() while not handle.fs do handle.update(delegate:receive()) end end,
344 function() sleep(2) end)
345 if not handle.fs then error("Connection failed: Server does not support filesystem transfers") end
346 local infile, err = fs.open(args[3], "rb")
347 if not infile then error("Could not open remote file: " .. (err or "Unknown error")) end
348 local outfile, err = handle.fs.open(args[4] or shell.resolve(fs.getName(args[3])), "wb")
349 if not outfile then
350 infile.close()
351 error("Could not open local file: " .. (err or "Unknown error"))
352 end
353 outfile.write(infile.readAll())
354 infile.close()
355 outfile.close()
356 handle.close()
357 print("Uploaded file as " .. (args[4] or shell.resolve(fs.getName(args[3]))))
358elseif (args[1] == "ls" or args[1] == "list") and args[2] then
359 local modem
360 if args[4] then
361 if peripheral.getType(args[5]) ~= "modem" then error("Peripheral on selected side is not a modem.") end
362 modem = peripheral.wrap(args[5])
363 end
364 local handle, delegate = connect(args[2], modem, nil)
365 parallel.waitForAny(
366 function() while not handle.fs do handle.update(delegate:receive()) end end,
367 function() sleep(2) end)
368 if not handle.fs then error("Connection failed: Server does not support filesystem transfers") end
369 local files = handle.fs.list(args[3] or "/")
370 local fileList, dirList = {}, {}
371 local showHidden = settings.get("list.show_hidden")
372 for _, v in pairs(files) do
373 if showHidden or v:sub(1, 1) ~= "." then
374 local path = fs.combine(args[3] or "/", v)
375 if handle.fs.isDir(path) then dirList[#dirList+1] = v
376 else fileList[#fileList+1] = v end
377 end
378 end
379 handle.close()
380 table.sort(dirList)
381 table.sort(fileList)
382 if term.isColor() then textutils.pagedTabulate(colors.green, dirList, colors.white, fileList)
383 else textutils.pagedTabulate(colors.lightGray, dirList, colors.white, fileList) end
384elseif args[1] == "status" then
385 hasRedrun, redrun = pcall(require, "redrun")
386 if hasRedrun then
387 local id = redrun.getid("rawshell_server")
388 if not id then print("Status: Server is not running.")
389 else print("Status: Server is running as ID " .. id .. ".") end
390 else error("Background task running requires the RedRun library.") end
391elseif args[1] == "stop" then
392 hasRedrun, redrun = pcall(require, "redrun")
393 if hasRedrun then
394 local id = redrun.getid("rawshell_server")
395 if not id then error("Server is not running.") end
396 redrun.terminate(id)
397 else error("Background task running requires the RedRun library.") end
398else
399 term.setTextColor(colors.red)
400 textutils.pagedPrint[[
401Usage:
402 rawshell connect <id> [side]
403 rawshell get <id> <remote path> [local path] [side]
404 rawshell put <id> <local path> <remote path> [side]
405 raswhell ls <id> [remote path]
406 rawshell serve [-c <program>] [-m <side>] [-p <password>] [-w <url>] [-b] [-s]
407 rawshell status
408 rawshell stop
409Arguments:
410 <id> The ID of the server to connect to, or a WebSocket URL
411 -b Run in background (requires RedRun)
412 -c <program> Program to run on connection (defaults to "shell")
413 -m <side> / [side] Use modem attached to the selected side
414 -p <password> Require password to log in
415 -r <width>x<height> Set the resolution of the virtual screen
416 -s Use secure connection (requires ECC)
417 -w <url> Serve to a WebSocket URL instead of over a modem]]
418 term.setTextColor(colors.white)
419end