diff --git a/.gitignore b/.gitignore index a61013bd..8df12ba6 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,9 @@ AdaptixServer/extenders/*/dist/ # Go build artifacts AdaptixServer/adaptixserver +# AdaptixCLI binary +AdaptixCLI/adaptix-cil + # Build artifacts (object files, dependency files) *.o *.o.d diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..712b2172 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# AGENTS.md + +## Architecture + +- **`AdaptixServer/`** — Go server (module `AdaptixServer`). Entrypoint: `main.go`. +- **`AdaptixClient/`** — C++/Qt6 GUI client. Built via CMake (`CMakeLists.txt`). +- **`AdaptixServer/extenders/`** — 7 Go plugin modules (agents + listeners). Each has its own `go.mod` + `Makefile`. +- **`AdaptixCLI/`** — Go CLI tool for server interaction (independent module). Designed for AI/script automation. +- **`AdaptixServer/core/`** — Server internals: `connector/`, `server/`, `database/`, `extender/`, `profile/`, `eventing/`, `axscript/`, `utils/`. +- Go workspace file (`go.work`) ties the server module + all 7 extender modules together. + +## Build + +All builds use the top-level `Makefile`: + +| Command | What it builds | +|---------|---------------| +| `make all` | Server + client + extenders | +| `make server` | Go server only (requires `ssl_gen.sh`, `profile.yaml`, `404page.html` alongside binary) | +| `make server-ext` | Server + extenders (ideal for VPS, no client) | +| `make extenders` | All extender plugins only | +| `make client` | C++/Qt6 client (single-threaded) | +| `make client-fast` | C++/Qt6 client (parallel build) | +| `make cli` | `AdaptixCLI/` CLI tool only | +| `make install-cli` | Build CLI and copy to `/usr/local/bin` | + +**Important Go build flags**: The server and extenders require `GOEXPERIMENT=jsonv2,greenteagc`. The Makefile handles this — don't omit it if building manually. + +**Extenders are Go plugins**: Built with `-buildmode=plugin`, output as `.so` files. Their Makefiles also cross-compile the actual agent binaries (e.g., `objects_http/`, `objects_smb/`) via sub-make. + +**Client prerequisites**: Qt6, OpenSSL, CMake ≥ 3.28. On macOS with Homebrew: `brew install qt@6 cmake openssl`. On Linux see `pre_install_linux_all.sh`. + +**Windows client build**: Use `AdaptixClient/build.bat` (requires MSYS2/MinGW). + +## Run + +``` +./dist/adaptixserver -profile profile.yaml [-debug] +``` + +- Server listens on the interface/port defined in `profile.yaml` (default: `0.0.0.0:4321`). +- TLS certs are generated via `ssl_gen.sh` before first run. +- Client connects via the GUI dialog. + +## Docker + +- Build: `make docker-build-server`, `make docker-build-extenders`, `make docker-build-server-ext` +- Runtime: `make docker-up` / `make docker-down` / `make docker-logs` +- Profiles: `build-server`, `build-extenders`, `build-server-ext`, `build-client`, `runtime` + +## Repo conventions + +- **Active dev branch**: `dev-v1.3`. All PRs go to the `dev` branch (see README CONTRIBUTING). +- **No tests exist** — there are no `_test.go` files, no test runner config, no CI pipelines. +- **No linter/formatter config** — no `.golangci.yml`, `.clang-format`, or pre-commit hooks. +- Go version: **1.26.1** (`go.mod`, `go.work`). The Docker runtime installs `go1.26.4`. +- Server module name is `AdaptixServer` (capital A, no dot). Extender module names use underscore convention (e.g., `adaptix_agent_beacon`). + +## Key files + +| File | Purpose | +|------|---------| +| `AdaptixServer/profile.yaml` | Server config (listeners, extenders, TLS, auth) | +| `AdaptixServer/ssl_gen.sh` | Generate self-signed RSA certs | +| `AdaptixServer/404page.html` | Default HTTP 404 error page | +| `AdaptixServer/go.work` | Multi-module Go workspace | +| `AdaptixClient/CMakeLists.txt` | Client build definition, lists all source files | +| `AdaptixCLI/go.mod` | CLI tool module (standalone, not in go.work) | +| `Makefile` | Top-level build orchestrator | diff --git a/AdaptixCLI/client.go b/AdaptixCLI/client.go new file mode 100644 index 00000000..569a31a7 --- /dev/null +++ b/AdaptixCLI/client.go @@ -0,0 +1,255 @@ +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + + "net/url" + "time" + + "github.com/gorilla/websocket" +) + +const clientType uint8 = 3 + +type Client struct { + cfg *Config + baseURL string + wsURL string + accessToken string + refreshToken string + + httpClient *http.Client + wsConn *websocket.Conn +} + +type apiResponse struct { + OK bool `json:"ok"` + Message string `json:"message"` +} + +func newClient(cfg *Config) *Client { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + }, + } + return &Client{ + cfg: cfg, + baseURL: fmt.Sprintf("https://%s:%d%s", cfg.Host, cfg.Port, cfg.Endpoint), + wsURL: fmt.Sprintf("wss://%s:%d%s", cfg.Host, cfg.Port, cfg.Endpoint), + httpClient: &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) doJSON(method, path string, body interface{}) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, c.baseURL+path, bodyReader) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.accessToken != "" { + req.Header.Set("Authorization", "Bearer "+c.accessToken) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode == http.StatusNetworkAuthenticationRequired { + return nil, fmt.Errorf("already connected from another session") + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +} + +func (c *Client) doAPI(method, path string, body interface{}) (*apiResponse, []byte, error) { + respBody, err := c.doJSON(method, path, body) + if err != nil { + return nil, nil, err + } + + var apiResp apiResponse + if err := json.Unmarshal(respBody, &apiResp); err == nil && apiResp.Message != "" || !apiResp.OK { + if !apiResp.OK && apiResp.Message != "" { + return &apiResp, nil, fmt.Errorf("%s", apiResp.Message) + } + return &apiResp, respBody, nil + } + + return &apiResp, respBody, nil +} + +func (c *Client) login() error { + respBody, err := c.doJSON("POST", "/login", map[string]string{ + "username": c.cfg.Username, + "password": c.cfg.Password, + }) + if err != nil { + return err + } + + var result struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Version string `json:"version"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("parse login response: %w", err) + } + c.accessToken = result.AccessToken + c.refreshToken = result.RefreshToken + return nil +} + +func (c *Client) getOTP(otpType string, data interface{}) (string, error) { + respBody, err := c.doJSON("POST", "/otp/generate", map[string]interface{}{ + "type": otpType, + "data": data, + }) + if err != nil { + return "", err + } + + var result struct { + OK bool `json:"ok"` + Message string `json:"message"` + } + json.Unmarshal(respBody, &result) + if !result.OK { + return "", fmt.Errorf("OTP failed: %s", result.Message) + } + return result.Message, nil +} + +func (c *Client) connectChannelWebSocket(otpType string, data interface{}) (*websocket.Conn, error) { + otp, err := c.getOTP(otpType, data) + if err != nil { + return nil, fmt.Errorf("get channel OTP: %w", err) + } + + u, _ := url.Parse(c.wsURL + "/channel") + q := u.Query() + q.Set("otp", otp) + u.RawQuery = q.Encode() + + dialer := websocket.Dialer{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + HandshakeTimeout: 10 * time.Second, + } + conn, resp, err := dialer.Dial(u.String(), nil) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNetworkAuthenticationRequired { + return nil, fmt.Errorf("channel connect failed (main client not connected)") + } + return nil, fmt.Errorf("channel dial: %w", err) + } + return conn, nil +} + +func (c *Client) connectWebSocket() error { + otpData := map[string]interface{}{ + "client_type": clientType, + "console_team_mode": false, + "subscriptions": []string{"agents", "console", "tasks", "downloads", "listeners"}, + } + otp, err := c.getOTP("connect", otpData) + if err != nil { + return fmt.Errorf("get OTP: %w", err) + } + + u, _ := url.Parse(c.wsURL + "/connect") + q := u.Query() + q.Set("otp", otp) + u.RawQuery = q.Encode() + + dialer := websocket.Dialer{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + HandshakeTimeout: 10 * time.Second, + } + conn, resp, err := dialer.Dial(u.String(), nil) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNetworkAuthenticationRequired { + return fmt.Errorf("client already connected - disconnect the GUI client first") + } + return fmt.Errorf("websocket dial: %w", err) + } + c.wsConn = conn + return nil +} + +func (c *Client) waitSync() error { + if c.wsConn == nil { + return fmt.Errorf("not connected") + } + + for { + _, msg, err := c.wsConn.ReadMessage() + if err != nil { + c.wsConn = nil + return fmt.Errorf("ws read during sync: %w", err) + } + + var packet struct { + Type int `json:"type"` + } + if err := json.Unmarshal(msg, &packet); err != nil { + continue + } + + if packet.Type == 0x12 { + return nil + } + } +} + +func (c *Client) wsReadMessage() ([]byte, error) { + if c.wsConn == nil { + return nil, fmt.Errorf("not connected") + } + _, msg, err := c.wsConn.ReadMessage() + if err != nil { + c.wsConn = nil + } + return msg, err +} + +func (c *Client) disconnect() { + if c.wsConn != nil { + c.wsConn.Close() + c.wsConn = nil + } +} + +func (c *Client) httpDo(req *http.Request) (*http.Response, error) { + return c.httpClient.Do(req) +} + + diff --git a/AdaptixCLI/commands.go b/AdaptixCLI/commands.go new file mode 100644 index 00000000..363d147e --- /dev/null +++ b/AdaptixCLI/commands.go @@ -0,0 +1,557 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "strings" + "sync/atomic" + "time" +) + +var interruptFlag int32 + +func init() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + go func() { + for range sigCh { + atomic.StoreInt32(&interruptFlag, 1) + } + }() +} + +type agentData struct { + Id string `json:"a_id"` + Name string `json:"a_name"` + Listener string `json:"a_listener"` + Async bool `json:"a_async"` + ExternalIP string `json:"a_external_ip"` + InternalIP string `json:"a_internal_ip"` + Sleep uint `json:"a_sleep"` + Jitter uint `json:"a_jitter"` + Pid string `json:"a_pid"` + Tid string `json:"a_tid"` + Arch string `json:"a_arch"` + Elevated bool `json:"a_elevated"` + Process string `json:"a_process"` + Os int `json:"a_os"` + OsDesc string `json:"a_os_desc"` + Domain string `json:"a_domain"` + Computer string `json:"a_computer"` + Username string `json:"a_username"` + Impersonated string `json:"a_impersonated"` + ACP int `json:"a_acp"` + OemCP int `json:"a_oemcp"` + CreateTime int64 `json:"a_create_time"` + LastTick int `json:"a_last_tick"` + Tags string `json:"a_tags"` + Mark string `json:"a_mark"` + Color string `json:"a_color"` +} + +type listenerData struct { + Name string `json:"l_name"` + Protocol string `json:"l_protocol"` + Type string `json:"l_type"` + BindHost string `json:"l_bind_host"` + BindPort string `json:"l_bind_port"` + Status string `json:"l_status"` +} + +type downloadData struct { + FileId string `json:"d_file_id"` + AgentId string `json:"d_agent_id"` + AgentName string `json:"d_agent_name"` + File string `json:"d_file"` + Size int64 `json:"d_size"` + RecvSize int64 `json:"d_recv_size"` + State int `json:"d_state"` + User string `json:"d_user"` + Computer string `json:"d_computer"` + Date int64 `json:"d_date"` +} + +func runAgentList(c *Client, jsonOut, onlineOnly bool) error { + respBody, err := c.doJSON("GET", "/agent/list", nil) + if err != nil { + return err + } + + var agents []agentData + if err := json.Unmarshal(respBody, &agents); err != nil { + var resp struct { + OK bool `json:"ok"` + Message string `json:"message"` + Agents []agentData `json:"agents"` + } + json.Unmarshal(respBody, &resp) + if !resp.OK { + return fmt.Errorf("server error: %s", resp.Message) + } + agents = resp.Agents + } + + if onlineOnly { + filtered := make([]agentData, 0, len(agents)) + for _, a := range agents { + if a.Mark != "Disconnect" { + filtered = append(filtered, a) + } + } + agents = filtered + } + + if len(agents) == 0 { + fmt.Println("no agents connected") + return nil + } + + if jsonOut { + printJSON(agents) + return nil + } + + tw := newTabWriter() + fmt.Fprintln(tw, "ID\tNAME\tOS\tUSER\tCOMPUTER\tPROCESS\tARCH\tLISTENER") + for _, a := range agents { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + truncate(a.Id, 12), + a.Name, + osName(a.Os)+" "+a.OsDesc, + a.Username, + a.Computer, + a.Process, + a.Arch, + a.Listener, + ) + } + tw.Flush() + return nil +} + +func runAgentInfo(c *Client, agentID string, jsonOut bool) error { + respBody, err := c.doJSON("GET", "/agent/list", nil) + if err != nil { + return err + } + + var agents []agentData + json.Unmarshal(respBody, &agents) + + var found *agentData + for i := range agents { + if strings.HasPrefix(agents[i].Id, agentID) { + found = &agents[i] + break + } + } + if found == nil { + return fmt.Errorf("agent %s not found", agentID) + } + + if jsonOut { + printJSON(found) + return nil + } + + fmt.Printf("ID: %s\n", found.Id) + fmt.Printf("Name: %s\n", found.Name) + fmt.Printf("OS: %s %s\n", osName(found.Os), found.OsDesc) + fmt.Printf("Computer: %s\n", found.Computer) + fmt.Printf("Domain: %s\n", found.Domain) + fmt.Printf("Username: %s\n", found.Username) + fmt.Printf("Internal IP: %s\n", found.InternalIP) + fmt.Printf("External IP: %s\n", found.ExternalIP) + fmt.Printf("Process: %s (PID: %s)\n", found.Process, found.Pid) + fmt.Printf("Arch: %s\n", found.Arch) + fmt.Printf("Elevated: %v\n", found.Elevated) + fmt.Printf("Listener: %s\n", found.Listener) + fmt.Printf("Sleep/Jitter: %d/%d\n", found.Sleep, found.Jitter) + fmt.Printf("Tags: %s\n", found.Tags) + fmt.Printf("Mark: %s\n", found.Mark) + return nil +} + +func runAgentRemove(c *Client, agentIDs []string) error { + respBody, err := c.doJSON("POST", "/agent/remove", map[string]interface{}{ + "agent_id_array": agentIDs, + }) + if err != nil { + return err + } + + var resp apiResponse + json.Unmarshal(respBody, &resp) + if resp.OK || resp.Message == "" { + fmt.Println("agents removed") + return nil + } + return fmt.Errorf("remove failed: %s", resp.Message) +} + +func runListenerList(c *Client, jsonOut bool) error { + respBody, err := c.doJSON("GET", "/listener/list", nil) + if err != nil { + return err + } + + var listeners []listenerData + json.Unmarshal(respBody, &listeners) + + if len(listeners) == 0 { + fmt.Println("no active listeners") + return nil + } + + if jsonOut { + printJSON(listeners) + return nil + } + + tw := newTabWriter() + fmt.Fprintln(tw, "NAME\tTYPE\tPROTOCOL\tBIND\tSTATUS") + for _, l := range listeners { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s:%s\t%s\n", + l.Name, l.Type, l.Protocol, l.BindHost, l.BindPort, l.Status, + ) + } + tw.Flush() + return nil +} + +func runListenerStart(c *Client, configType, name, config string) error { + respBody, err := c.doJSON("POST", "/listener/create", map[string]string{ + "name": name, + "type": configType, + "config": config, + }) + if err != nil { + return err + } + + var resp apiResponse + json.Unmarshal(respBody, &resp) + if resp.OK { + fmt.Println("listener started") + return nil + } + return fmt.Errorf("start failed: %s", resp.Message) +} + +func runListenerStop(c *Client, configType, name string) error { + respBody, err := c.doJSON("POST", "/listener/stop", map[string]string{ + "name": name, + "type": configType, + }) + if err != nil { + return err + } + + var resp apiResponse + json.Unmarshal(respBody, &resp) + if resp.OK { + fmt.Println("listener stopped") + return nil + } + return fmt.Errorf("stop failed: %s", resp.Message) +} + +func runDownloadList(c *Client, jsonOut bool) error { + respBody, err := c.doJSON("GET", "/download/list", nil) + if err != nil { + return err + } + + var downloads []downloadData + json.Unmarshal(respBody, &downloads) + + if len(downloads) == 0 { + fmt.Println("no downloads") + return nil + } + + if jsonOut { + printJSON(downloads) + return nil + } + + tw := newTabWriter() + fmt.Fprintln(tw, "FILE ID\tAGENT\tFILE\tSIZE\tSTATE") + for _, dl := range downloads { + size := "" + if dl.Size > 0 { + if dl.State == 3 { + size = formatBytes(dl.Size) + } else { + size = fmt.Sprintf("%s / %s", formatBytes(dl.RecvSize), formatBytes(dl.Size)) + } + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + truncate(dl.FileId, 12), + dl.AgentName, + dl.File, + size, + stateName(dl.State), + ) + } + tw.Flush() + return nil +} + +func runDownloadGet(c *Client, fileID, outputPath string) error { + respBody, err := c.doJSON("GET", "/download/list", nil) + if err != nil { + return err + } + + var downloads []downloadData + json.Unmarshal(respBody, &downloads) + + var found *downloadData + for i := range downloads { + if strings.HasPrefix(downloads[i].FileId, fileID) { + found = &downloads[i] + break + } + } + if found == nil { + return fmt.Errorf("download %s not found", fileID) + } + + otp, err := c.getOTP("download", map[string]string{"id": found.FileId}) + if err != nil { + return fmt.Errorf("get download OTP: %w", err) + } + + resp, err := c.httpClient.Get(c.baseURL + "/otp/download/sync?otp=" + otp) + if err != nil { + return fmt.Errorf("download request: %w", err) + } + defer resp.Body.Close() + + content, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read download: %w", err) + } + + if outputPath == "" { + outputPath = found.File + if idx := strings.LastIndex(outputPath, "/"); idx >= 0 { + outputPath = outputPath[idx+1:] + } + if idx := strings.LastIndex(outputPath, "\\"); idx >= 0 { + outputPath = outputPath[idx+1:] + } + } + + if err := os.WriteFile(outputPath, content, 0644); err != nil { + return fmt.Errorf("write file: %w", err) + } + fmt.Printf("downloaded %s -> %s (%s)\n", found.File, outputPath, formatBytes(int64(len(content)))) + return nil +} + +func runAgentExec(c *Client, agentID string, cmdline string, args []string, timeout time.Duration, jsonOut bool) error { + if err := c.connectWebSocket(); err != nil { + return fmt.Errorf("connect: %w", err) + } + defer c.disconnect() + + if err := c.syncAndWait(); err != nil { + return err + } + + return c.execCommand(agentID, cmdline, timeout) +} + +func (c *Client) syncAndWait() error { + if _, err := c.doJSON("POST", "/sync", map[string]interface{}{}); err != nil { + return fmt.Errorf("trigger sync: %w", err) + } + if err := c.waitSync(); err != nil { + return fmt.Errorf("sync: %w", err) + } + return nil +} + +func (c *Client) execCommand(agentID string, cmdline string, timeout time.Duration) error { + cmdBody := map[string]interface{}{ + "id": agentID, + "cmdline": cmdline, + } + respBody, err := c.doJSON("POST", "/agent/command/raw", cmdBody) + if err != nil { + return fmt.Errorf("send command: %w", err) + } + var resp apiResponse + json.Unmarshal(respBody, &resp) + if !resp.OK && resp.Message != "" { + return fmt.Errorf("command error: %s", resp.Message) + } + + return c.waitOutput(agentID, timeout) +} + +func (c *Client) waitOutput(agentID string, timeout time.Duration) error { + deadline := time.After(timeout) + var lastOutputTime time.Time + + for { + if c.wsConn == nil { + return nil + } + + if atomic.LoadInt32(&interruptFlag) == 1 { + atomic.StoreInt32(&interruptFlag, 0) + fmt.Print("^C\n") + return nil + } + + select { + case <-deadline: + return fmt.Errorf("timeout waiting for output") + default: + } + + c.wsConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + msg, err := c.wsReadMessage() + if err != nil { + if isTimeoutErr(err) { + if !lastOutputTime.IsZero() && time.Since(lastOutputTime) > 5*time.Second { + return nil + } + continue + } + return nil + } + c.wsConn.SetReadDeadline(time.Time{}) + + output, done := parseSyncPacket(msg, agentID) + if output != "" { + fmt.Print(output) + lastOutputTime = time.Now() + } + if done { + return nil + } + } +} + +func parseSyncPacket(msg []byte, agentID string) (output string, done bool) { + var packet struct { + Type int `json:"type"` + AgentId string `json:"a_id"` + MessageType int `json:"a_msg_type"` + Message string `json:"a_message"` + ClearText string `json:"a_text"` + Completed bool `json:"a_completed"` + } + if err := json.Unmarshal(msg, &packet); err != nil { + return "", false + } + + if packet.AgentId == "" { + return "", false + } + + isTargetAgent := packet.AgentId == agentID || strings.HasPrefix(agentID, packet.AgentId) || strings.HasPrefix(packet.AgentId, agentID) + if !isTargetAgent { + return "", false + } + + switch packet.Type { + case 0x67, 0x68, 0x69: + output := packet.Message + if packet.ClearText != "" { + output = packet.ClearText + } + return output, false + + case 0x49, 0x4a, 0x6a, 0x6b: + output := packet.Message + if packet.ClearText != "" { + output = packet.ClearText + } + return output, packet.Completed + + case 0x4d: + var hook struct { + Completed bool `json:"a_completed"` + Message string `json:"a_message"` + Text string `json:"a_text"` + } + json.Unmarshal(msg, &hook) + if hook.Text != "" { + return hook.Text, hook.Completed + } + if hook.Message != "" { + return hook.Message, hook.Completed + } + return "", hook.Completed + + default: + return "", false + } +} + +func getAgentInfo(c *Client, agentID string) *agentData { + respBody, err := c.doJSON("GET", "/agent/list", nil) + if err != nil { + return nil + } + var agents []agentData + json.Unmarshal(respBody, &agents) + for i := range agents { + if strings.HasPrefix(agents[i].Id, agentID) || strings.HasPrefix(agentID, agents[i].Id) { + return &agents[i] + } + } + return nil +} + +func detectShellType(a *agentData) string { + if a == nil { + return "sh" + } + switch a.Os { + case 1: // Windows + return "cmd" + case 2: // Linux + return "bash" + case 3: // macOS + return "sh" + default: + return "sh" + } +} + +func isTimeoutErr(err error) bool { + if err == nil { + return false + } + s := err.Error() + return strings.Contains(s, "timeout") || strings.Contains(s, "deadline") +} + +func formatBytes(n int64) string { + if n < 1024 { + return fmt.Sprintf("%d B", n) + } + if n < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(n)/1024) + } + if n < 1024*1024*1024 { + return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024)) + } + return fmt.Sprintf("%.1f GB", float64(n)/(1024*1024*1024)) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/AdaptixCLI/config.go b/AdaptixCLI/config.go new file mode 100644 index 00000000..2e5b5880 --- /dev/null +++ b/AdaptixCLI/config.go @@ -0,0 +1,136 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/goccy/go-yaml" +) + +type Config struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Endpoint string `yaml:"endpoint"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +func configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot find home directory: %w", err) + } + dir := filepath.Join(home, ".adaptix") + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("create config dir: %w", err) + } + return filepath.Join(dir, "adaptix-cil.yaml"), nil +} + +func loadConfig() (*Config, error) { + path, err := configPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("no config found, run 'adaptix-cil config set' first") + } + return nil, fmt.Errorf("read config: %w", err) + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + if cfg.Port == 0 { + cfg.Port = 4321 + } + if cfg.Endpoint == "" { + cfg.Endpoint = "/endpoint" + } + return &cfg, nil +} + +func saveConfig(cfg *Config) error { + path, err := configPath() + if err != nil { + return err + } + if cfg.Port == 0 { + cfg.Port = 4321 + } + if cfg.Endpoint == "" { + cfg.Endpoint = "/endpoint" + } + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} + +func runConfigSet(args []string) { + fs := newFlagSet("config set") + host := fs.String("host", "", "server host") + port := fs.Int("port", 0, "server port") + endpoint := fs.String("endpoint", "", "server endpoint") + username := fs.String("username", "", "operator username") + password := fs.String("password", "", "operator password") + _ = fs.Parse(args) + + cfg, _ := loadConfig() + if cfg == nil { + cfg = &Config{} + } + if *host != "" { + cfg.Host = *host + } + if *port != 0 { + cfg.Port = *port + } + if *endpoint != "" { + cfg.Endpoint = *endpoint + } + if *username != "" { + cfg.Username = *username + } + if *password != "" { + cfg.Password = *password + } + + if err := saveConfig(cfg); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + fmt.Println("config saved") +} + +func runConfigShow(jsonOut bool) { + cfg, err := loadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + if jsonOut { + printJSON(cfg) + } else { + fmt.Printf("host: %s\n", cfg.Host) + fmt.Printf("port: %d\n", cfg.Port) + fmt.Printf("endpoint: %s\n", cfg.Endpoint) + fmt.Printf("username: %s\n", cfg.Username) + fmt.Printf("password: %s\n", maskPassword(cfg.Password)) + } +} + +func maskPassword(p string) string { + if len(p) <= 4 { + return "****" + } + return p[:2] + "****" + p[len(p)-2:] +} diff --git a/AdaptixCLI/go.mod b/AdaptixCLI/go.mod new file mode 100644 index 00000000..069b7ec6 --- /dev/null +++ b/AdaptixCLI/go.mod @@ -0,0 +1,11 @@ +module AdaptixCLI + +go 1.26.1 + +require ( + github.com/goccy/go-yaml v1.19.2 + github.com/gorilla/websocket v1.5.3 + golang.org/x/term v0.35.0 +) + +require golang.org/x/sys v0.36.0 // indirect diff --git a/AdaptixCLI/go.sum b/AdaptixCLI/go.sum new file mode 100644 index 00000000..85eeb154 --- /dev/null +++ b/AdaptixCLI/go.sum @@ -0,0 +1,8 @@ +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= diff --git a/AdaptixCLI/main.go b/AdaptixCLI/main.go new file mode 100644 index 00000000..37096391 --- /dev/null +++ b/AdaptixCLI/main.go @@ -0,0 +1,409 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + "time" +) + +var ( + flagHost string + flagPort int + flagEndpoint string + flagUsername string + flagPassword string + flagJSON bool + flagOnline bool + flagShellType string + flagTimeout string + flagOutput string + flagRows int + flagCols int +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + cmd := os.Args[1] + args := os.Args[2:] + + switch cmd { + case "config": + runConfigCmd(args) + + case "agent": + runAgentCmd(args) + + case "listener": + runListenerCmd(args) + + case "download": + runDownloadCmd(args) + + case "exec": + runExecCmd(args) + + case "-h", "--help", "help": + printUsage() + + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) + printUsage() + os.Exit(1) + } +} + +func runConfigCmd(args []string) { + if len(args) == 0 { + printUsage() + os.Exit(1) + } + switch args[0] { + case "set": + runConfigSet(args[1:]) + case "show": + parseArgs("config show", args[1:], true) + runConfigShow(flagJSON) + default: + fmt.Fprintf(os.Stderr, "unknown config command: %s\n", args[0]) + os.Exit(1) + } +} + +func runAgentCmd(args []string) { + if len(args) == 0 { + printUsage() + os.Exit(1) + } + switch args[0] { + case "list": + fs := newFlagSet("agent list") + addGlobalFlags(fs) + fs.BoolVar(&flagJSON, "json", false, "JSON output") + fs.BoolVar(&flagOnline, "online", false, "only show online agents") + _ = fs.Parse(args[1:]) + mergeConfig() + execWithClient(func(c *Client) error { + return runAgentList(c, flagJSON, flagOnline) + }) + case "info": + extra := parseArgs("agent info", args[1:], true) + if len(extra) == 0 { + fmt.Fprintln(os.Stderr, "usage: adaptix-cil agent info ") + os.Exit(1) + } + execWithClient(func(c *Client) error { + return runAgentInfo(c, extra[0], flagJSON) + }) + case "remove": + extra := parseArgs("agent remove", args[1:], true) + if len(extra) == 0 { + fmt.Fprintln(os.Stderr, "usage: adaptix-cil agent remove ...") + os.Exit(1) + } + execWithClient(func(c *Client) error { + return runAgentRemove(c, extra) + }) + case "exec": + runExecCmd(args[1:]) + case "terminal": + fs := newFlagSet("agent terminal") + addGlobalFlags(fs) + fs.StringVar(&flagShellType, "shell", "", "shell type: sh, bash, cmd, powershell (auto-detect by default)") + fs.IntVar(&flagRows, "rows", 0, "terminal rows") + fs.IntVar(&flagCols, "cols", 0, "terminal columns") + flags, pos := extractFlags(fs, args[1:]) + if err := fs.Parse(flags); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + mergeConfig() + if len(pos) == 0 { + fmt.Fprintln(os.Stderr, "usage: adaptix-cil agent terminal [--shell sh|bash|cmd|powershell]") + os.Exit(1) + } + agentID := pos[0] + execWithClient(func(c *Client) error { + return runAgentTerminal(c, agentID, flagShellType, flagRows, flagCols) + }) + default: + fmt.Fprintf(os.Stderr, "unknown agent command: %s\n", args[0]) + os.Exit(1) + } +} + +func runListenerCmd(args []string) { + if len(args) == 0 { + printUsage() + os.Exit(1) + } + switch args[0] { + case "list": + parseArgs("listener list", args[1:], true) + execWithClient(func(c *Client) error { + return runListenerList(c, flagJSON) + }) + case "start": + extra := parseArgs("listener start", args[1:], true) + if len(extra) < 3 { + fmt.Fprintln(os.Stderr, "usage: adaptix-cil listener start ") + os.Exit(1) + } + execWithClient(func(c *Client) error { + return runListenerStart(c, extra[0], extra[1], extra[2]) + }) + case "stop": + extra := parseArgs("listener stop", args[1:], true) + if len(extra) < 2 { + fmt.Fprintln(os.Stderr, "usage: adaptix-cil listener stop ") + os.Exit(1) + } + execWithClient(func(c *Client) error { + return runListenerStop(c, extra[0], extra[1]) + }) + default: + fmt.Fprintf(os.Stderr, "unknown listener command: %s\n", args[0]) + os.Exit(1) + } +} + +func runDownloadCmd(args []string) { + if len(args) == 0 { + printUsage() + os.Exit(1) + } + switch args[0] { + case "list": + parseArgs("download list", args[1:], true) + execWithClient(func(c *Client) error { + return runDownloadList(c, flagJSON) + }) + case "get": + fs := newFlagSet("download get") + addGlobalFlags(fs) + fs.StringVar(&flagOutput, "o", "", "output file path") + _ = fs.Parse(args[1:]) + mergeConfig() + if fs.NArg() == 0 { + fmt.Fprintln(os.Stderr, "usage: adaptix-cil download get [-o path]") + os.Exit(1) + } + execWithClient(func(c *Client) error { + return runDownloadGet(c, fs.Arg(0), flagOutput) + }) + default: + fmt.Fprintf(os.Stderr, "unknown download command: %s\n", args[0]) + os.Exit(1) + } +} + +func runExecCmd(args []string) { + fs := newFlagSet("exec") + addGlobalFlags(fs) + fs.BoolVar(&flagJSON, "json", false, "JSON output") + fs.StringVar(&flagTimeout, "timeout", "60s", "command timeout") + _ = fs.Parse(args) + mergeConfig() + + if fs.NArg() < 2 { + printUsage() + os.Exit(1) + } + + agentID := fs.Arg(0) + cmdline := fs.Arg(1) + extraArgs := fs.Args()[2:] + + timeout, err := time.ParseDuration(flagTimeout) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid timeout: %v\n", err) + os.Exit(1) + } + + execWithClient(func(c *Client) error { + return runAgentExec(c, agentID, cmdline, extraArgs, timeout, flagJSON) + }) +} + +func newFlagSet(name string) *flag.FlagSet { + return flag.NewFlagSet(name, flag.ContinueOnError) +} + +func addGlobalFlags(fs *flag.FlagSet) { + fs.StringVar(&flagHost, "host", "", "server host") + fs.IntVar(&flagPort, "port", 0, "server port") + fs.StringVar(&flagEndpoint, "endpoint", "", "server endpoint") + fs.StringVar(&flagUsername, "username", "", "operator username") + fs.StringVar(&flagPassword, "password", "", "operator password") +} + +func parseArgs(name string, args []string, addJSON bool) []string { + fs := newFlagSet(name) + addGlobalFlags(fs) + if addJSON { + fs.BoolVar(&flagJSON, "json", false, "JSON output") + } + + flags, positionals := extractFlags(fs, args) + err := fs.Parse(flags) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + mergeConfig() + return positionals +} + +func extractFlags(fs *flag.FlagSet, args []string) (flags []string, positionals []string) { + i := 0 + for i < len(args) { + arg := args[i] + if strings.HasPrefix(arg, "--") { + name := strings.TrimPrefix(arg, "--") + if eqIdx := strings.IndexByte(name, '='); eqIdx >= 0 { + name = name[:eqIdx] + } + if fs.Lookup(name) != nil { + flags = append(flags, arg) + if eqIdx := strings.IndexByte(arg, '='); eqIdx < 0 { + f := fs.Lookup(name) + if f != nil { + if _, isBool := f.Value.(interface{ IsBoolFlag() bool }); !isBool { + i++ + if i < len(args) { + flags = append(flags, args[i]) + } + } + } + } + i++ + continue + } + } else if strings.HasPrefix(arg, "-") && len(arg) > 1 { + name := strings.TrimPrefix(arg, "-") + if eqIdx := strings.IndexByte(name, '='); eqIdx >= 0 { + name = name[:eqIdx] + } + if fs.Lookup(name) != nil { + flags = append(flags, arg) + if eqIdx := strings.IndexByte(arg, '='); eqIdx < 0 { + f := fs.Lookup(name) + if f != nil { + if _, isBool := f.Value.(interface{ IsBoolFlag() bool }); !isBool { + i++ + if i < len(args) { + flags = append(flags, args[i]) + } + } + } + } + i++ + continue + } + } + positionals = append(positionals, arg) + i++ + } + return +} + +func mergeConfig() { + if flagHost != "" || flagPort != 0 || flagEndpoint != "" || flagUsername != "" || flagPassword != "" { + return + } + cfg, err := loadConfig() + if err != nil { + return + } + if flagHost == "" { + flagHost = cfg.Host + } + if flagPort == 0 { + flagPort = cfg.Port + } + if flagEndpoint == "" { + flagEndpoint = cfg.Endpoint + } + if flagUsername == "" { + flagUsername = cfg.Username + } + if flagPassword == "" { + flagPassword = cfg.Password + } +} + +func getEffectiveConfig() *Config { + mergeConfig() + if flagHost == "" { + fmt.Fprintln(os.Stderr, "error: no host configured. Run 'adaptix-cil config set' or use --host") + os.Exit(1) + } + return &Config{ + Host: flagHost, + Port: flagPort, + Endpoint: flagEndpoint, + Username: flagUsername, + Password: flagPassword, + } +} + +func execWithClient(fn func(*Client) error) { + cfg := getEffectiveConfig() + c := newClient(cfg) + if err := c.login(); err != nil { + fmt.Fprintf(os.Stderr, "error: login failed: %v\n", err) + os.Exit(1) + } + if err := fn(c); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + c.disconnect() + os.Exit(1) + } + c.disconnect() +} + +func printUsage() { + fmt.Println(`AdaptixCIL - CLI tool for AdaptixC2 server + +USAGE: + adaptix-cil config set [--host HOST] [--port PORT] [--endpoint EP] [--username U] [--password P] + adaptix-cil config show [--json] + + adaptix-cil agent list [--online] [--json] + adaptix-cil agent info [--json] + adaptix-cil agent exec [--timeout DUR] + adaptix-cil agent terminal [--shell sh|bash|cmd|powershell] [--rows N] [--cols N] + adaptix-cil agent remove ... + + adaptix-cil listener list [--json] + adaptix-cil listener start + adaptix-cil listener stop + + adaptix-cil download list [--json] + adaptix-cil download get [-o PATH] + +GLOBAL OPTIONS: + --host HOST Server host (overrides config) + --port PORT Server port (overrides config) + --endpoint EP Server endpoint (overrides config) + --username U Operator username (overrides config) + --password P Operator password (overrides config) + --json Output in JSON format + +EXAMPLES: + adaptix-cil config set --host 10.0.0.1 --port 4321 --username admin --password pass + adaptix-cil agent list --online --json + adaptix-cil agent exec abc123 "shell whoami" + adaptix-cil agent terminal abc123 + adaptix-cil agent terminal abc123 --shell powershell + + adaptix-cil download list + adaptix-cil download get 1a2b3c -o /tmp/out.bin`) +} + + diff --git a/AdaptixCLI/output.go b/AdaptixCLI/output.go new file mode 100644 index 00000000..25c53464 --- /dev/null +++ b/AdaptixCLI/output.go @@ -0,0 +1,62 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" +) + +func printJSON(v interface{}) { + data, _ := json.MarshalIndent(v, "", " ") + fmt.Println(string(data)) +} + +func newTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) +} + +func osName(osType int) string { + switch osType { + case 1: + return "Windows" + case 2: + return "Linux" + case 3: + return "macOS" + default: + return fmt.Sprintf("? (%d)", osType) + } +} + +func stateName(state int) string { + switch state { + case 1: + return "RUNNING" + case 2: + return "STOPPED" + case 3: + return "FINISHED" + case 4: + return "CANCELED" + default: + return fmt.Sprintf("? (%d)", state) + } +} + +func taskTypeName(t int) string { + switch t { + case 0: + return "LOCAL" + case 1: + return "TASK" + case 2: + return "BROWSER" + case 3: + return "JOB" + case 4: + return "TUNNEL" + default: + return fmt.Sprintf("? (%d)", t) + } +} diff --git a/AdaptixCLI/terminal.go b/AdaptixCLI/terminal.go new file mode 100644 index 00000000..9b242a99 --- /dev/null +++ b/AdaptixCLI/terminal.go @@ -0,0 +1,120 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + + "github.com/gorilla/websocket" + "golang.org/x/term" +) + +func runAgentTerminal(c *Client, agentID string, shellType string, rows int, cols int) error { + if err := c.connectWebSocket(); err != nil { + return fmt.Errorf("connect: %w", err) + } + defer c.disconnect() + + if err := c.syncAndWait(); err != nil { + return err + } + + agent := getAgentInfo(c, agentID) + if shellType == "" { + shellType = detectShellType(agent) + } + program := shellToProgram(shellType) + + terminalID := generateHexID(8) + + fd := int(os.Stdin.Fd()) + tw, th, err := term.GetSize(fd) + if err == nil { + rows = th + cols = tw + } else if rows == 0 { + rows = 24 + } + if cols == 0 { + cols = 80 + } + oemCP := 0 + if agent != nil { + oemCP = agent.OemCP + } + + termConfig := map[string]interface{}{ + "agent_id": agentID, + "terminal_id": terminalID, + "program": program, + "size_h": rows, + "size_w": cols, + "oem_cp": oemCP, + } + + channelWS, err := c.connectChannelWebSocket("channel_terminal", termConfig) + if err != nil { + return fmt.Errorf("open terminal channel: %w", err) + } + defer channelWS.Close() + + oldState, err := term.MakeRaw(fd) + if err != nil { + return fmt.Errorf("make raw: %w", err) + } + defer term.Restore(fd, oldState) + + done := make(chan struct{}, 2) + + go func() { + buf := make([]byte, 4096) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + break + } + if err := channelWS.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + break + } + } + done <- struct{}{} + }() + + go func() { + for { + _, msg, err := channelWS.ReadMessage() + if err != nil { + break + } + os.Stdout.Write(msg) + } + done <- struct{}{} + }() + + <-done + close(done) + channelWS.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + return nil +} + +func shellToProgram(shell string) string { + switch shell { + case "bash": + return "/bin/bash" + case "sh": + return "/bin/sh" + case "cmd": + return "cmd.exe" + case "powershell", "ps": + return "powershell" + default: + return "/bin/sh" + } +} + +func generateHexID(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b)[:n] +} diff --git a/Makefile b/Makefile index 1af08d06..72bc9f26 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,20 @@ server: prepare server-ext: clean server extenders +### CLI ### + +cli: prepare + @ echo "[*] Building AdaptixCLI..." + @ cd AdaptixCLI && GOEXPERIMENT=jsonv2,greenteagc go build -ldflags="-s -w" -o adaptix-cil . > /dev/null 2>build_error.log || { echo "[ERROR] Failed to build AdaptixCLI:"; cat build_error.log >&2; exit 1; } + @ mv ./AdaptixCLI/adaptix-cil ./$(DIST_DIR)/ + @ echo "[+] done" + +install-cli: cli + @ echo "[*] Installing adaptix-cil to /usr/local/bin..." + @ sudo cp ./$(DIST_DIR)/adaptix-cil /usr/local/bin/ + @ echo "[+] installed" + + ### CLIENT ### @@ -162,6 +176,8 @@ help: @ echo " server - Build only the server" @ echo " client-fast - Build only the client in multithread mode (fast build)" @ echo " client - Build only the client" + @ echo " cli - Build only the CLI tool" + @ echo " install-cli - Build CLI and install to /usr/local/bin" @ echo " clean - Remove dist directory" @ echo " clean-all - Remove all build artifacts" @ echo "" @@ -182,4 +198,4 @@ help: @ echo "" @ echo "Platform: $(UNAME_S) [$(NPROC) proc]" -.PHONY: all extenders server-ext server client clean clean-all docker-build-server docker-build-extenders docker-build-server-ext docker-build-all docker-up docker-down docker-logs docker-restart docker-clean docker-clean-all help prepare +.PHONY: all extenders server-ext server client cli install-cli clean clean-all docker-build-server docker-build-extenders docker-build-server-ext docker-build-all docker-up docker-down docker-logs docker-restart docker-clean docker-clean-all help prepare