Skip to content

Commit 554c49d

Browse files
committed
feat: add file transfer API
1 parent 72c82c8 commit 554c49d

6 files changed

Lines changed: 604 additions & 29 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ docker run --rm -p 8765:8765 -e BASE_URL=http://localhost:8765 cya
2626
2. Target machine runs the install command for its OS.
2727
3. The Go bridge connects to `/ws` and joins the session.
2828
4. Agents call `/api/session/:code/run` with `cmd`, `cmd_b64`, and optional `timeout`.
29-
5. The response includes merged stdout/stderr, `exit_code`, and `truncated`.
29+
5. Agents can transfer files:
30+
- POST `/api/session/:code/files/send` with `path` and `content_b64` to write a file on the target.
31+
- POST `/api/session/:code/files/receive` with `path` to read a target file and get a `download_url`.
32+
- GET the returned `/api/session/:code/files/download/:id` URL to download the received file.
33+
6. Command responses include merged stdout/stderr, `exit_code`, and `truncated`.
3034

3135
Sessions are in-memory only. Nothing is persisted by the server.
3236

bridge/main.go

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package main
22

33
import (
44
"bytes"
5+
"encoding/base64"
56
"encoding/json"
67
"io"
78
"net/http"
89
"os"
910
"os/exec"
1011
"os/signal"
1112
osuser "os/user"
13+
"path/filepath"
1214
"regexp"
1315
"runtime"
1416
"strings"
@@ -150,17 +152,31 @@ func safeUser() string {
150152
func isElevated() bool {
151153
if runtime.GOOS == "windows" {
152154
u, err := osuser.Current()
153-
if err == nil && strings.EqualFold(u.Username, "Administrator") {
154-
return true
155+
currentUser := ""
156+
if err == nil {
157+
currentUser = u.Username
155158
}
156-
return strings.EqualFold(os.Getenv("USERNAME"), "Administrator")
159+
return isWindowsAdministrator(currentUser, os.Getenv("USERNAME"))
157160
}
158161
if os.Getenv("SUDO_UID") != "" {
159162
return true
160163
}
161164
return syscall.Geteuid() == 0
162165
}
163166

167+
func isWindowsAdministrator(currentUser, envUser string) bool {
168+
return windowsUsernameLeaf(currentUser) == "administrator" ||
169+
windowsUsernameLeaf(envUser) == "administrator"
170+
}
171+
172+
func windowsUsernameLeaf(value string) string {
173+
value = strings.TrimSpace(value)
174+
if idx := strings.LastIndexAny(value, `\\/`); idx >= 0 {
175+
value = value[idx+1:]
176+
}
177+
return strings.ToLower(value)
178+
}
179+
164180
func hostnameSafe() string {
165181
h, err := os.Hostname()
166182
if err != nil {
@@ -248,6 +264,37 @@ func readCommands(wsc *wsConn, dot string) {
248264
"exit_code": code,
249265
"truncated": truncated,
250266
})
267+
case "file_write":
268+
var msg struct {
269+
ID string `json:"id"`
270+
Path string `json:"path"`
271+
ContentB64 string `json:"content_b64"`
272+
}
273+
_ = json.Unmarshal(data, &msg)
274+
if msg.ID == "" {
275+
continue
276+
}
277+
ok, bytesWritten, errMsg := writeFileB64(msg.Path, msg.ContentB64)
278+
wsc.sendJSON(map[string]any{
279+
"type": "file_write_result",
280+
"id": msg.ID,
281+
"ok": ok,
282+
"bytes": bytesWritten,
283+
"error": errMsg,
284+
})
285+
case "file_read":
286+
var msg struct {
287+
ID string `json:"id"`
288+
Path string `json:"path"`
289+
}
290+
_ = json.Unmarshal(data, &msg)
291+
if msg.ID == "" {
292+
continue
293+
}
294+
result := readFileB64(msg.Path)
295+
result["type"] = "file_read_result"
296+
result["id"] = msg.ID
297+
wsc.sendJSON(result)
251298
case "bye":
252299
wsc.close()
253300
return
@@ -268,6 +315,39 @@ func runOneShot(cmdLine string) (output string, status int, truncated bool) {
268315
return output, childExitCode(err), truncated
269316
}
270317

318+
func writeFileB64(path, contentB64 string) (bool, int, string) {
319+
if strings.TrimSpace(path) == "" {
320+
return false, 0, "missing path"
321+
}
322+
content, err := base64.StdEncoding.DecodeString(contentB64)
323+
if err != nil {
324+
return false, 0, "invalid base64 content"
325+
}
326+
if err := os.WriteFile(path, content, 0600); err != nil {
327+
return false, 0, err.Error()
328+
}
329+
return true, len(content), ""
330+
}
331+
332+
func readFileB64(path string) map[string]any {
333+
if strings.TrimSpace(path) == "" {
334+
return map[string]any{"ok": false, "error": "missing path"}
335+
}
336+
content, err := os.ReadFile(path)
337+
if err != nil {
338+
return map[string]any{"ok": false, "path": path, "error": err.Error()}
339+
}
340+
contentType := http.DetectContentType(content)
341+
return map[string]any{
342+
"ok": true,
343+
"path": path,
344+
"filename": filepath.Base(path),
345+
"content_type": contentType,
346+
"content_b64": base64.StdEncoding.EncodeToString(content),
347+
"bytes": len(content),
348+
}
349+
}
350+
271351
func trimOutput(output string) (string, bool) {
272352
if len(output) <= maxOutputBytes {
273353
return output, false

bridge/main_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,27 @@ func TestShellMetadataHelpers(t *testing.T) {
7777
}
7878
}
7979

80+
func TestWindowsAdminUsernameDetection(t *testing.T) {
81+
cases := []struct {
82+
name string
83+
currentUser string
84+
envUser string
85+
want bool
86+
}{
87+
{name: "plain administrator", currentUser: "Administrator", want: true},
88+
{name: "domain administrator", currentUser: `WINBOX\\Administrator`, want: true},
89+
{name: "env administrator fallback", envUser: "Administrator", want: true},
90+
{name: "normal user", currentUser: `WINBOX\\bagas`, envUser: "bagas", want: false},
91+
}
92+
for _, tt := range cases {
93+
t.Run(tt.name, func(t *testing.T) {
94+
if got := isWindowsAdministrator(tt.currentUser, tt.envUser); got != tt.want {
95+
t.Fatalf("isWindowsAdministrator() = %v, want %v", got, tt.want)
96+
}
97+
})
98+
}
99+
}
100+
80101
func TestOneShotArgs(t *testing.T) {
81102
name, args := oneShotArgs("echo ok")
82103
if runtime.GOOS == "windows" {
@@ -108,6 +129,22 @@ func TestRunOneShotMergesStdoutStderrAndExitCode(t *testing.T) {
108129
}
109130
}
110131

132+
func TestFileTransferHelpers(t *testing.T) {
133+
path := t.TempDir() + "/hello.txt"
134+
ok, bytesWritten, errMsg := writeFileB64(path, "aGVsbG8=")
135+
if !ok || bytesWritten != 5 || errMsg != "" {
136+
t.Fatalf("writeFileB64() = %v, %d, %q", ok, bytesWritten, errMsg)
137+
}
138+
139+
result := readFileB64(path)
140+
if result["ok"] != true || result["content_b64"] != "aGVsbG8=" || result["bytes"] != 5 {
141+
t.Fatalf("unexpected readFileB64 result: %+v", result)
142+
}
143+
if result["filename"] != "hello.txt" {
144+
t.Fatalf("unexpected filename: %+v", result["filename"])
145+
}
146+
}
147+
111148
func TestReadCommandsExecutesCommandAndSendsResult(t *testing.T) {
112149
client, server := websocketPair(t)
113150
defer server.Close()
@@ -158,6 +195,67 @@ func TestReadCommandsExecutesCommandAndSendsResult(t *testing.T) {
158195
}
159196
}
160197

198+
func TestReadCommandsHandlesFileTransferMessages(t *testing.T) {
199+
client, server := websocketPair(t)
200+
defer server.Close()
201+
202+
done := make(chan struct{})
203+
go func() {
204+
readCommands(&wsConn{conn: client}, "")
205+
close(done)
206+
}()
207+
208+
path := t.TempDir() + "/ws-file.txt"
209+
if err := server.WriteJSON(map[string]any{
210+
"type": "file_write",
211+
"id": "write-1",
212+
"path": path,
213+
"content_b64": "aGVsbG8=",
214+
}); err != nil {
215+
t.Fatalf("write file_write: %v", err)
216+
}
217+
var writeResult struct {
218+
Type string `json:"type"`
219+
ID string `json:"id"`
220+
OK bool `json:"ok"`
221+
Bytes int `json:"bytes"`
222+
}
223+
if err := server.ReadJSON(&writeResult); err != nil {
224+
t.Fatalf("read file_write result: %v", err)
225+
}
226+
if writeResult.Type != "file_write_result" || writeResult.ID != "write-1" || !writeResult.OK || writeResult.Bytes != 5 {
227+
t.Fatalf("unexpected file_write result: %+v", writeResult)
228+
}
229+
230+
if err := server.WriteJSON(map[string]any{
231+
"type": "file_read",
232+
"id": "read-1",
233+
"path": path,
234+
}); err != nil {
235+
t.Fatalf("write file_read: %v", err)
236+
}
237+
var readResult struct {
238+
Type string `json:"type"`
239+
ID string `json:"id"`
240+
OK bool `json:"ok"`
241+
ContentB64 string `json:"content_b64"`
242+
Bytes int `json:"bytes"`
243+
}
244+
if err := server.ReadJSON(&readResult); err != nil {
245+
t.Fatalf("read file_read result: %v", err)
246+
}
247+
if readResult.Type != "file_read_result" || readResult.ID != "read-1" || !readResult.OK || readResult.ContentB64 != "aGVsbG8=" || readResult.Bytes != 5 {
248+
t.Fatalf("unexpected file_read result: %+v", readResult)
249+
}
250+
251+
_ = server.WriteJSON(map[string]any{"type": "bye"})
252+
select {
253+
case <-done:
254+
case <-time.After(2 * time.Second):
255+
t.Fatalf("readCommands did not exit after bye")
256+
}
257+
}
258+
161259
func TestReadCommandsIgnoresMalformedMessages(t *testing.T) {
162260
client, server := websocketPair(t)
163261
defer server.Close()

0 commit comments

Comments
 (0)