Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 |
255 changes: 255 additions & 0 deletions AdaptixCLI/client.go
Original file line number Diff line number Diff line change
@@ -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)
}


Loading