Skip to content

Commit 5ed2deb

Browse files
authored
Agent commands (#139)
1 parent b1a5726 commit 5ed2deb

27 files changed

+1576
-484
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ cd backend
2525
python3 -m venv env
2626
source env/bin/activate
2727
uv pip install -r requirements.txt
28-
DEBUG=1 alembic ugprade head
28+
DEBUG=1 alembic upgrade head
2929

3030
cd ../frontend
3131
npm install

agent/frameos_agent.nimble

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ requires "nim >= 2.0.0"
1515
requires "ws >= 0.5.0"
1616
requires "jsony >= 1.1.5"
1717
requires "nimcrypto >= 0.6.0"
18+
requires "checksums >= 0.2.1"
19+
requires "zippy >= 0.10.16"

agent/frameos_agent.service

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ ExecStart=/srv/frameos/agent/current/frameos_agent
1212
Restart=always
1313
RestartSec=5
1414
LimitNOFILE=65536
15-
NoNewPrivileges=yes
1615
PrivateTmp=yes
1716
ProtectSystem=full
1817

agent/nimble.lock

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
{
22
"version": 2,
33
"packages": {
4+
"checksums": {
5+
"version": "0.2.1",
6+
"vcsRevision": "f8f6bd34bfa3fe12c64b919059ad856a96efcba0",
7+
"url": "https://github.com/nim-lang/checksums",
8+
"downloadMethod": "git",
9+
"dependencies": [],
10+
"checksums": {
11+
"sha1": "d3c7a1d0c0dee8fa089bd7f4d474e005877b608a"
12+
}
13+
},
414
"jsony": {
515
"version": "1.1.5",
616
"vcsRevision": "ea811bec7fa50f5abd3088ba94cda74285e93f18",
@@ -30,6 +40,16 @@
3040
"checksums": {
3141
"sha1": "ae4daf4ae302d0431f3c2d385ae9d2fe767a3246"
3242
}
43+
},
44+
"zippy": {
45+
"version": "0.10.16",
46+
"vcsRevision": "a99f6a7d8a8e3e0213b3cad0daf0ea974bf58e3f",
47+
"url": "https://github.com/guzba/zippy",
48+
"downloadMethod": "git",
49+
"dependencies": [],
50+
"checksums": {
51+
"sha1": "da3bb5ea388f980babcc29760348e2899d29a639"
52+
}
3353
}
3454
},
3555
"tasks": {}

agent/src/frameos_agent.nim

Lines changed: 221 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import std/[algorithm, segfaults, strformat, strutils, asyncdispatch, terminal,
2-
times, os, sysrand]
2+
times, os, sysrand, httpclient, osproc, streams, unicode]
3+
import checksums/md5
34
import json, jsony
45
import ws
56
import nimcrypto
67
import nimcrypto/hmac
8+
import zippy
79

810
const
911
DefaultConfigPath* = "./frame.json" # secure location
@@ -23,7 +25,10 @@ type
2325
wifiHotspotSsid*: string
2426
wifiHotspotPassword*: string
2527
wifiHotspotTimeoutSeconds*: float
26-
agentConnection*: bool
28+
29+
AgentConfig* = ref object
30+
agentEnabled*: bool
31+
agentRunCommands*: bool
2732
agentSharedSecret*: string
2833

2934
FrameConfig* = ref object
@@ -45,6 +50,7 @@ type
4550
debug*: bool
4651
timeZone*: string
4752
network*: NetworkConfig
53+
agent*: AgentConfig
4854

4955
# ----------------------------------------------------------------------------
5056
# Config IO (fails hard if unreadable)
@@ -71,12 +77,12 @@ proc hmacSha256Hex(key, data: string): string =
7177
# ----------------------------------------------------------------------------
7278
proc sign(data: string; cfg: FrameConfig): string =
7379
## data = the “open” string we want to protect
74-
## secret = cfg.network.agentSharedSecret (never leaves the device)
80+
## secret = cfg.agent.agentSharedSecret (never leaves the device)
7581
## apiKey = cfg.serverApiKey (public “username”)
7682
##
7783
## The server re-creates exactly the same byte-sequence:
7884
## apiKey || data (no separators, keep order)
79-
result = hmacSha256Hex(cfg.network.agentSharedSecret,
85+
result = hmacSha256Hex(cfg.agent.agentSharedSecret,
8086
cfg.serverApiKey & data)
8187

8288
# ----------------------------------------------------------------------------
@@ -122,7 +128,21 @@ proc verifyEnvelope(node: JsonNode; cfg: FrameConfig): bool =
122128
node.hasKey("serverApiKey") and
123129
node["serverApiKey"].getStr == cfg.serverApiKey and
124130
node["mac"].getStr.toLowerAscii() ==
125-
sign($node["nonce"].getInt & $node["payload"], cfg)
131+
sign($node["nonce"].getInt & canonical(node["payload"]), cfg)
132+
133+
proc sendResp(ws: WebSocket; cfg: FrameConfig;
134+
id: string; ok: bool; res: JsonNode) {.async.} =
135+
let env = makeSecureEnvelope(%*{
136+
"type": "cmd/resp", "id": id, "ok": ok, "result": res
137+
}, cfg)
138+
await ws.send($env)
139+
140+
proc streamChunk(ws: WebSocket; cfg: FrameConfig;
141+
id: string; which: string; data: string) {.async.} =
142+
let env = makeSecureEnvelope(%*{
143+
"type": "cmd/stream", "id": id, "stream": which, "data": data
144+
}, cfg)
145+
await ws.send($env)
126146

127147
# ----------------------------------------------------------------------------
128148
# utils – tiny print-and-quit helper
@@ -156,9 +176,192 @@ proc recvText(ws: WebSocket): Future[string] {.async.} =
156176
else:
157177
discard # ignore binary, pong …
158178

179+
proc recvBinary(ws: WebSocket): Future[string] {.async.} =
180+
## Wait for the next *binary* frame, replying to pings automatically.
181+
while true:
182+
let (opcode, payload) = await ws.receivePacket()
183+
case opcode
184+
of Opcode.Binary:
185+
return cast[string](payload)
186+
of Opcode.Ping:
187+
await ws.send(payload, OpCode.Pong)
188+
of Opcode.Close:
189+
if payload.len >= 2:
190+
let code = (uint16(payload[0]) shl 8) or uint16(payload[1])
191+
let reason = if payload.len > 2:
192+
cast[string](payload[2 .. ^1])
193+
else: ""
194+
raise newException(Exception,
195+
&"connection closed by server (code {code}): {reason}")
196+
else:
197+
raise newException(Exception, "connection closed by server")
198+
else:
199+
discard # ignore text, pong …
200+
159201
# ----------------------------------------------------------------------------
160202
# Challenge-response handshake (server-initiated)
161203
# ----------------------------------------------------------------------------
204+
proc handleCmd(cmd: JsonNode; ws: WebSocket; cfg: FrameConfig): Future[void] {.async.} =
205+
let id = cmd{"id"}.getStr()
206+
let name = cmd{"name"}.getStr()
207+
let args = cmd{"args"}
208+
209+
echo &"📥 cmd: {name}({args})"
210+
211+
# No remote execution available
212+
if not cfg.agent.agentRunCommands:
213+
if name != "version":
214+
await sendResp(ws, cfg, id, false, %*{"error": "agentRunCommands disabled in config"})
215+
return
216+
217+
try:
218+
case name
219+
of "version":
220+
await sendResp(ws, cfg, id, true, %*{"version": "0.0.0"})
221+
222+
of "http":
223+
let methodArg = args{"method"}.getStr("GET")
224+
let path = args{"path"}.getStr("/")
225+
let bodyArg = args{"body"}.getStr("")
226+
227+
var client = newAsyncHttpClient()
228+
if args.hasKey("headers"):
229+
for k, v in args["headers"].pairs:
230+
try:
231+
client.headers.add(k, v.getStr())
232+
except Exception:
233+
discard # ignore malformed header values
234+
let url = &"http://127.0.0.1:{cfg.framePort}{path}"
235+
let resp = await (if methodArg == "POST": client.post(url, bodyArg) else: client.get(url))
236+
237+
let bodyBytes = await resp.body # raw bytes
238+
var hdrs = %*{}
239+
for k, v in resp.headers: hdrs[k.toLowerAscii()] = %*v
240+
241+
# ---------- send binary when body is not UTF-8 ---------- #
242+
let isBinary = bodyBytes.validateUtf8() >= 0
243+
if isBinary:
244+
# ── 1. stream the bytes FIRST ───────────────────────────────
245+
var sent = 0
246+
const chunk = 65536
247+
while sent < bodyBytes.len:
248+
let endPos = min(sent + chunk, bodyBytes.len)
249+
await ws.send(bodyBytes[sent ..< endPos], OpCode.Binary)
250+
sent = endPos
251+
252+
# ── 2. JSON reply AFTER all chunks ──────────────────────────
253+
await sendResp(ws, cfg, id, true, %*{
254+
"status": resp.code.int,
255+
"size": bodyBytes.len,
256+
"headers": hdrs,
257+
"binary": true # flag for backend
258+
})
259+
else:
260+
await sendResp(
261+
ws, cfg, id, true,
262+
%*{"status": resp.code.int,
263+
"body": cast[string](bodyBytes),
264+
"headers": hdrs,
265+
"binary": false})
266+
of "shell":
267+
if not args.hasKey("cmd"):
268+
await sendResp(ws, cfg, id, false,
269+
%*{"error": "`cmd` missing"})
270+
return
271+
272+
let cmdStr = args["cmd"].getStr
273+
274+
var p = startProcess(
275+
"/bin/sh", # command
276+
args = ["-c", cmdStr], # argv
277+
options = {poUsePath, poStdErrToStdOut}
278+
)
279+
280+
let bufSize = 4096
281+
var buf = newString(bufSize)
282+
283+
while true:
284+
let n = p.outputStream.readData(addr buf[0], buf.len)
285+
if n == 0:
286+
if p.running: await sleepAsync(100) # no data yet – yield
287+
else: break # process finished – exit loop
288+
else:
289+
await streamChunk(ws, cfg, id, "stdout", buf[0 ..< n])
290+
291+
let rc = p.waitForExit()
292+
await sendResp(ws, cfg, id, rc == 0, %*{"exit": rc})
293+
294+
of "file_md5":
295+
let path = args{"path"}.getStr("")
296+
if path.len == 0:
297+
await sendResp(ws, cfg, id, false, %*{"error": "`path` missing"})
298+
elif not fileExists(path):
299+
await sendResp(ws, cfg, id, true, %*{"exists": false, "md5": ""})
300+
else:
301+
let contents = readFile(path)
302+
let digest = getMD5(contents)
303+
await sendResp(ws, cfg, id, true,
304+
%*{"exists": true, "md5": $digest.toLowerAscii()})
305+
306+
of "file_read":
307+
let path = args{"path"}.getStr("")
308+
if path.len == 0:
309+
await sendResp(ws, cfg, id, false, %*{"error": "`path` missing"})
310+
elif not fileExists(path):
311+
await sendResp(ws, cfg, id, false, %*{"error": "file not found"})
312+
else:
313+
let raw = readFile(path)
314+
let zipped = compress(raw)
315+
var sent = 0
316+
const chunkSize = 65536
317+
while sent < zipped.len:
318+
let chunkEnd = min(sent + chunkSize, zipped.len)
319+
await ws.send(zipped[sent ..< chunkEnd], OpCode.Binary)
320+
sent = chunkEnd
321+
await sendResp(ws, cfg, id, true, %*{"size": raw.len})
322+
323+
of "file_write":
324+
let path = args{"path"}.getStr("")
325+
let size = args{"size"}.getInt(0)
326+
if path.len == 0 or size <= 0:
327+
await sendResp(ws, cfg, id, false, %*{"error": "`path`/`size` missing"})
328+
else:
329+
try:
330+
var received = 0
331+
var zipped = newStringOfCap(size) # capacity only, len = 0
332+
while received < size:
333+
let chunk = await recvBinary(ws)
334+
zipped.add chunk
335+
received = zipped.len
336+
let bytes = uncompress(zipped)
337+
writeFile(path, bytes)
338+
await sendResp(ws, cfg, id, true, %*{"written": bytes.len})
339+
except CatchableError as e:
340+
await sendResp(ws, cfg, id, false, %*{"error": e.msg})
341+
342+
of "assets_list":
343+
let root = args{"path"}.getStr("")
344+
if root.len == 0:
345+
await sendResp(ws, cfg, id, false,
346+
%*{"error": "`path` missing"})
347+
elif not dirExists(root):
348+
await sendResp(ws, cfg, id, false,
349+
%*{"error": "dir not found"})
350+
else:
351+
var items = newSeq[JsonNode]()
352+
for path in walkDirRec(root):
353+
let fi = getFileInfo(path)
354+
items.add %*{
355+
"path": path,
356+
"size": fi.size,
357+
"mtime": fi.lastWriteTime.toUnix()
358+
}
359+
await sendResp(ws, cfg, id, true, %*{"assets": items})
360+
else:
361+
await sendResp(ws, cfg, id, false,
362+
%*{"error": "unknown command: " & name})
363+
except CatchableError as e:
364+
await sendResp(ws, cfg, id, false, %*{"error": e.msg})
162365

163366
proc doHandshake(ws: WebSocket; cfg: FrameConfig): Future[void] {.async.} =
164367
## Implements the protocol:
@@ -171,9 +374,9 @@ proc doHandshake(ws: WebSocket; cfg: FrameConfig): Future[void] {.async.} =
171374
echo "⚠️ serverApiKey is empty, cannot connect"
172375
raise newException(Exception, "⚠️ serverApiKey is empty, cannot connect")
173376

174-
if len(cfg.network.agentSharedSecret) == 0:
377+
if len(cfg.agent.agentSharedSecret) == 0:
175378
echo "⚠️ agentSharedSecret is empty, cannot connect"
176-
raise newException(Exception, "⚠️ network.agentSharedSecret is empty, cannot connect")
379+
raise newException(Exception, "⚠️ agent.agentSharedSecret is empty, cannot connect")
177380

178381
# --- Step 0: say hello ----------------------------------------------------
179382
var hello = %*{
@@ -193,7 +396,7 @@ proc doHandshake(ws: WebSocket; cfg: FrameConfig): Future[void] {.async.} =
193396
let challenge = challengeJson["c"].getStr
194397

195398
# --- Step 2: answer -------------------------------------------------------
196-
let mac = if cfg.network.agentSharedSecret.len > 0:
399+
let mac = if cfg.agent.agentSharedSecret.len > 0:
197400
sign(challenge, cfg)
198401
else: ""
199402
let reply = %*{
@@ -254,8 +457,11 @@ proc runAgent(cfg: FrameConfig) {.async.} =
254457
if not verifyEnvelope(node, cfg):
255458
echo "⚠️ bad MAC – dropping packet"; continue
256459
let payload = node["payload"]
257-
echo &"📥 {payload}"
258-
# TODO: handle backend commands …
460+
case payload{"type"}.getStr("")
461+
of "cmd":
462+
await handleCmd(payload, ws, cfg)
463+
else:
464+
echo &"📥 {payload}"
259465

260466
finally:
261467
if not ws.isNil:
@@ -276,6 +482,11 @@ proc runAgent(cfg: FrameConfig) {.async.} =
276482
when isMainModule:
277483
try:
278484
var cfg = loadConfig()
485+
if not cfg.agent.agentEnabled:
486+
echo "ℹ️ agentEnabled = false → no websocket connection started. Exiting in 10s."
487+
waitFor sleepAsync(10_000)
488+
quit(0) # graceful, zero-exit
489+
279490
waitFor runAgent(cfg)
280491
except Exception as e:
281492
fatal(e.msg)

0 commit comments

Comments
 (0)