1
1
import std/ [algorithm, segfaults, strformat, strutils, asyncdispatch, terminal,
2
- times, os, sysrand]
2
+ times, os, sysrand, httpclient, osproc, streams, unicode]
3
+ import checksums/ md5
3
4
import json, jsony
4
5
import ws
5
6
import nimcrypto
6
7
import nimcrypto/ hmac
8
+ import zippy
7
9
8
10
const
9
11
DefaultConfigPath * = " ./frame.json" # secure location
23
25
wifiHotspotSsid* : string
24
26
wifiHotspotPassword* : string
25
27
wifiHotspotTimeoutSeconds* : float
26
- agentConnection* : bool
28
+
29
+ AgentConfig * = ref object
30
+ agentEnabled* : bool
31
+ agentRunCommands* : bool
27
32
agentSharedSecret* : string
28
33
29
34
FrameConfig * = ref object
45
50
debug* : bool
46
51
timeZone* : string
47
52
network* : NetworkConfig
53
+ agent* : AgentConfig
48
54
49
55
# ----------------------------------------------------------------------------
50
56
# Config IO (fails hard if unreadable)
@@ -71,12 +77,12 @@ proc hmacSha256Hex(key, data: string): string =
71
77
# ----------------------------------------------------------------------------
72
78
proc sign (data: string ; cfg: FrameConfig ): string =
73
79
# # 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)
75
81
# # apiKey = cfg.serverApiKey (public “username”)
76
82
# #
77
83
# # The server re-creates exactly the same byte-sequence:
78
84
# # apiKey || data (no separators, keep order)
79
- result = hmacSha256Hex (cfg.network .agentSharedSecret,
85
+ result = hmacSha256Hex (cfg.agent .agentSharedSecret,
80
86
cfg.serverApiKey & data)
81
87
82
88
# ----------------------------------------------------------------------------
@@ -122,7 +128,21 @@ proc verifyEnvelope(node: JsonNode; cfg: FrameConfig): bool =
122
128
node.hasKey (" serverApiKey" ) and
123
129
node[" serverApiKey" ].getStr == cfg.serverApiKey and
124
130
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)
126
146
127
147
# ----------------------------------------------------------------------------
128
148
# utils – tiny print-and-quit helper
@@ -156,9 +176,192 @@ proc recvText(ws: WebSocket): Future[string] {.async.} =
156
176
else :
157
177
discard # ignore binary, pong …
158
178
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
+
159
201
# ----------------------------------------------------------------------------
160
202
# Challenge-response handshake (server-initiated)
161
203
# ----------------------------------------------------------------------------
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})
162
365
163
366
proc doHandshake (ws: WebSocket ; cfg: FrameConfig ): Future [void ] {.async .} =
164
367
# # Implements the protocol:
@@ -171,9 +374,9 @@ proc doHandshake(ws: WebSocket; cfg: FrameConfig): Future[void] {.async.} =
171
374
echo " ⚠️ serverApiKey is empty, cannot connect"
172
375
raise newException (Exception , " ⚠️ serverApiKey is empty, cannot connect" )
173
376
174
- if len (cfg.network .agentSharedSecret) == 0 :
377
+ if len (cfg.agent .agentSharedSecret) == 0 :
175
378
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" )
177
380
178
381
# --- Step 0: say hello ----------------------------------------------------
179
382
var hello = %* {
@@ -193,7 +396,7 @@ proc doHandshake(ws: WebSocket; cfg: FrameConfig): Future[void] {.async.} =
193
396
let challenge = challengeJson[" c" ].getStr
194
397
195
398
# --- Step 2: answer -------------------------------------------------------
196
- let mac = if cfg.network .agentSharedSecret.len > 0 :
399
+ let mac = if cfg.agent .agentSharedSecret.len > 0 :
197
400
sign (challenge, cfg)
198
401
else : " "
199
402
let reply = %* {
@@ -254,8 +457,11 @@ proc runAgent(cfg: FrameConfig) {.async.} =
254
457
if not verifyEnvelope (node, cfg):
255
458
echo " ⚠️ bad MAC – dropping packet" ; continue
256
459
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} "
259
465
260
466
finally :
261
467
if not ws.isNil:
@@ -276,6 +482,11 @@ proc runAgent(cfg: FrameConfig) {.async.} =
276
482
when isMainModule :
277
483
try :
278
484
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
+
279
490
waitFor runAgent (cfg)
280
491
except Exception as e:
281
492
fatal (e.msg)
0 commit comments