diff --git a/README.md b/README.md index fd8cbd7..420a3c6 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,224 @@ -# Hayduk: the open-source Metasploit GUI and Armitage alternative +# Hayduk: Open-source Metasploit GUI and Armitage alternative [![CI](https://github.com/jolovicdev/hayduk/actions/workflows/ci.yml/badge.svg)](https://github.com/jolovicdev/hayduk/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/jolovicdev/hayduk?display_name=tag)](https://github.com/jolovicdev/hayduk/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-informational)](LICENSE) -[![Go 1.26.1+](https://img.shields.io/badge/go-1.26.1%2B-00ADD8)](https://go.dev) - -Hayduk is a free, open-source **Metasploit GUI** built as a modern -Armitage alternative. This graphical attack management console is a -single Go binary with a browser UI. Hayduk connects to -[msfrpcd](https://docs.rapid7.com/metasploit/rpc-api/) through -[go-msf](https://github.com/jolovicdev/go-msf) and uses the modules, -payloads and Meterpreter sessions from your existing Metasploit installation. - -**For authorized security testing only.** - -![Hayduk demo: launch a module from the tree, attack the detected port, land a live shell session](docs/demo.gif) - -## What you get - -- **Live network topology**: the graph Armitage made famous, with hosts - grouped by subnet, access states, pivot routes drawn as dashed edges, - node positions that survive reloads -- **Campaign workflows**: host discovery and service scans, login attacks - with pre-filled recovered credentials, and Find attacks matching exploits - to a host's services -- **Hail Mary**: the Armitage signature move launches every matching exploit - against the selected hosts; launches are paced and recorded in the event log -- **Sessions**: interact with Meterpreter and shell sessions, upgrade shells - to Meterpreter or terminate sessions; output streams live with the real - prompt and busy state -- **Module launcher**: the full module tree with reliability ranks, option - editing, payload selection -- **Credentials, loot and events**: everything the workspace database - knows, plus an attributed event log -- **Report export**: one self-contained HTML document summarizing the - campaign for review and client delivery -- **Team mode**: several operators on one shared campaign (trusted - networks) + +Hayduk is a free, open-source **Metasploit GUI** for authorized penetration testing. It brings the Armitage workflow to your browser: map hosts, browse modules, manage Meterpreter and shell sessions, and export campaign reports. + +**Download one binary. Run it. Connect to Metasploit.** No Java, Go, or Node.js installation is required to run a release binary. The browser UI is included. + +Hayduk connects to a separate Metasploit Framework instance through `msfrpcd`. It does not bundle Metasploit. + +**[Download the latest release](https://github.com/jolovicdev/hayduk/releases/latest)** · [Quickstart](#quickstart) · [Try the Docker lab](#try-the-docker-lab) · [Team mode](#team-mode) + +![Hayduk Metasploit GUI demo showing module selection, a detected service, and a live shell session](docs/demo.gif) ## Quickstart -Download a prebuilt binary for Linux, macOS or Windows on amd64 or arm64 -from the -[releases page](https://github.com/jolovicdev/hayduk/releases/latest). -Each platform archive contains one static Hayduk binary. Hayduk itself -needs no JVM, installer or runtime libraries. It connects to a separate -Metasploit instance. +You need a Hayduk release binary, a browser, and a running Metasploit Framework instance. For workspace features such as hosts, services, credentials, and loot, Metasploit also needs a connected database. -For Linux on amd64: +The steps below assume Hayduk and Metasploit run on the same machine. If you need a ready-made Metasploit setup with a database and disposable targets, use the [Docker lab](#try-the-docker-lab). - tar xf hayduk_*_linux_amd64.tar.gz && ./hayduk +### 1. Download and extract Hayduk -Prefer to build from source? You need Go 1.26.1+ and Node: +Open the [latest release](https://github.com/jolovicdev/hayduk/releases/latest) and choose the archive for your operating system and processor: - make # builds the UI, embeds it, compiles bin/hayduk - ./bin/hayduk +| Your machine | Release platform | Architecture | +|---|---|---| +| Linux on Intel or AMD 64-bit | `linux` | `amd64` | +| Linux on ARM64 | `linux` | `arm64` | +| macOS on Intel | `darwin` | `amd64` | +| macOS on Apple silicon | `darwin` | `arm64` | +| Windows on Intel or AMD 64-bit | `windows` | `amd64` | +| Windows on ARM64 | `windows` | `arm64` | -Either way, the console opens in your browser. Point it at a running -`msfrpcd`. On Kali Linux, install Metasploit Framework first if needed: +Extract the archive into a folder. There is no Hayduk installer or separate UI setup. - sudo apt install metasploit-framework +### 2. Start Metasploit RPC -Then start its RPC daemon: +On the machine running Metasploit, open a terminal and run: - msfrpcd -P yourpassword -S -f -a 127.0.0.1 +```bash +msfrpcd -P 'yourpassword' -S -f -a 127.0.0.1 -p 55553 +``` -or spin the disposable docker lab used by the integration tests: +Replace `yourpassword` with your own password. Keep this terminal open. This command binds RPC to localhost on port `55553`; `-S` disables SSL for this local connection. - scripts/msf/up.sh # msfrpcd on 127.0.0.1:55553, user msf / testpass123 - scripts/msf/up.sh --with-vulnbox # plus disposable target boxes to scan and attack - scripts/msf/down.sh +If you already run `msfrpcd`, use its existing connection settings instead of starting another instance. -The demo above was recorded against exactly that lab — one command, no -targets of your own needed. +### 3. Run Hayduk -## Why Hayduk +Open another terminal in the extracted folder. -Armitage established the graphical attack management workflow for -Metasploit, but its -[latest commit](https://github.com/rsmudge/armitage/commit/c8ca6c00b5584444ef3c3a8e32341f43974567bd) -was in July 2016. Rapid7 -[removed msfgui and Armitage](https://github.com/rapid7/metasploit-framework/commit/1112daaff2d493a51ab7fb4e8c823e21a1ac9edd) -from the Metasploit Framework distribution in April 2013 and -[ended availability of Metasploit Community Edition](https://www.rapid7.com/blog/post/2019/07/18/end-of-sale-announced-for-metasploit-community/) -in July 2019. +**Linux and macOS:** -Hayduk rebuilds that workflow around the Metasploit RPC API. It keeps the -live graph, Hail Mary and shared campaigns while replacing the Java desktop -client with a browser and a single Go binary. +```bash +./hayduk +``` -## FAQ +**Windows PowerShell:** + +```powershell +.\hayduk.exe +``` + +Hayduk opens the UI in your browser. If the browser does not open, copy the full URL printed in the terminal, including `?token=...`. The port is assigned automatically. + +### 4. Connect + +In **Connect to msfrpcd**, enter the settings from step 2: + +| Field | Value | +|---|---| +| Host | `127.0.0.1` | +| Port | `55553` | +| User | `msf` | +| Password | The password you set above | +| use SSL | Unchecked | + +Click **Connect**. A cold Metasploit instance can take about half a minute to respond; connection progress appears in the dialog. -### Is Hayduk an Armitage replacement? +Keep Hayduk running while you use the UI. Press `Ctrl+C` in its terminal to stop it. -Hayduk follows the same attack management model, including the network -graph, Hail Mary and shared campaigns. It is a separate implementation -that drives `msfrpcd` directly and uses a browser instead of a Java client. +## Your first campaign -### Does Hayduk include Metasploit? +Use a system or network you are authorized to test. Scans run from the connected Metasploit instance, so targets must be reachable from that machine. -No. Hayduk is an `msfrpcd` client for the Metasploit Framework you already -run, including Kali's `metasploit-framework` package. Install Metasploit, -start `msfrpcd`, then point Hayduk at it. +Right-click hosts, sessions, table rows, and modules in the tree to open their action menus. -### Which platforms does Hayduk run on? +1. **Choose a workspace.** Click the **workspace** chip to switch between existing Metasploit workspaces, or use the current workspace. The active workspace scopes the database tables, topology, and exported report, keeping each client's campaign data separate. Events and sessions retain their originating workspace for report attribution; the **Sessions** tab shows sessions across workspaces. +2. **Discover hosts.** Open **Campaign → Discover hosts…**, enter your target host or CIDR range, select a scanner, and click **Configure…**. Review the module options and click **Launch**. +3. **Scan and inspect services.** Open **Campaign → Scan services…** and configure a scan for your target. Review the options before launching it. Open **View → Topology** to see discovered hosts, or **View → Services** to inspect service results. +4. **Launch an exploit and open a session.** Right-click an exploit module in the tree and choose **Launch…**, or open **Campaign → Find attacks…** and click a match's **Launch** button to prefill the target host and matched port. Review the module options and payload, then click **Launch**. If a session opens, click its row in the **Sessions** tab, or right-click its host and choose **Interact with session **, to open the live console in **Interact**. +5. **Export a report.** Choose **File → Export report…** to download a self-contained HTML campaign report. -Prebuilt Hayduk binaries support Linux, macOS and Windows on amd64 and -arm64. Hayduk can connect to `msfrpcd` running on another machine. +![Hayduk browser interface with network topology and Metasploit campaign controls](docs/screenshot.png) + +## Features + +| Capability | What you can do | +|---|---| +| Network topology | View hosts grouped by subnet, access states, and pivot routes; retain node positions across reloads. | +| Metasploit modules | Browse the module tree, inspect reliability ranks, configure options, and select payloads. | +| Campaign workflows | Discover hosts, scan services, and find exploit candidates matching known services. | +| Session management | Interact with Meterpreter and shell sessions, upgrade shells, and terminate sessions. | +| Credentials and loot | Review workspace data and use recovered credentials in login workflows. | +| Hail Mary | Launch matching exploits against selected hosts, with paced launches and an event log. | +| Reporting | Export a self-contained HTML report for campaign review and client delivery. | +| Team mode | Share a campaign with multiple operators on a trusted network. | + +## Try the Docker lab + +The repository includes a disposable Metasploit lab with a database and optional target containers. The demo above uses this lab. + +You need Git, Docker, and Docker Compose. Run these commands from a shell that supports the repository's `.sh` scripts: + +```bash +git clone https://github.com/jolovicdev/hayduk.git +cd hayduk +scripts/msf/up.sh --with-vulnbox +``` + +The script prints the target container IP addresses when the lab is ready. Start your downloaded Hayduk binary and connect with **Host** `127.0.0.1`, **Port** `55553`, **User** `msf`, **Password** `testpass123`, and **use SSL** unchecked. Use a printed target IP for your first scan. + +To start only Metasploit and its database, run `scripts/msf/up.sh` without `--with-vulnbox`. + +When finished, run this from the repository root. It removes the lab containers and their volumes, including lab database data: + +```bash +scripts/msf/down.sh +``` ## Team mode - ./bin/hayduk --team --listen 192.168.1.10:8787 +Run the downloaded binary with a specific interface address that your operators can reach: + +```bash +./hayduk --team --listen 192.168.1.10:8787 +``` + +Replace `192.168.1.10` with your machine's address. Team mode requires an explicit, non-loopback address; wildcard addresses such as `0.0.0.0` are refused. + +Share the full token URL printed in the terminal. Each operator opens it in a browser and chooses a name. The event log attributes actions to those names. + +Team mode uses one shared token and plain HTTP. Operator names are labels, not verified identities. Treat the token URL like a password and use team mode only on trusted networks. Read the [security policy](SECURITY.md) for the full trust model. + +## Troubleshooting + +| Problem | What to check | +|---|---| +| The browser does not open | Open the full token URL printed by Hayduk. You can also start with `./hayduk --no-browser`. | +| Hayduk cannot connect | Check that `msfrpcd` is running and that the host, port, user, and password match. | +| SSL connection fails | Leave **use SSL** unchecked when `msfrpcd` runs with `-S`; enable it when the daemon uses SSL. | +| Connection takes time | Watch the connection dialog. A cold Metasploit instance can take about half a minute to respond. | +| Hosts or services are missing | Check the selected workspace, Metasploit's database connection, and target reachability from Metasploit. | + +## FAQ + +### Is Hayduk an Armitage alternative? + +Yes. Hayduk follows Armitage's graphical Metasploit workflow, including network topology, module launching, Hail Mary, and shared campaigns. It is a separate implementation with a browser UI and a single Go binary. + +### Does Hayduk include Metasploit Framework? + +No. Hayduk is a GUI client for Metasploit's `msfrpcd` service. Use your existing Metasploit installation or the included Docker lab. + +### Do I need Go, Node.js, Java, or Docker? + +No additional language runtime is required for the Hayduk release binary. Go and Node.js are needed to build from source. Docker is needed only if you choose the included lab. + +### Can Hayduk connect to Metasploit on another machine? + +Yes. Enter the Metasploit machine's reachable address in **Host** and match its RPC port, credentials, and SSL setting. The localhost-only RPC command in Quickstart accepts connections only from the same machine. + +### Is Hayduk free and open source? + +Yes. Hayduk is released under the [MIT license](LICENSE). + +## Build from source + +For development, use Go 1.26.1 or newer, Node.js 24 as used in CI, npm, and Make. From the repository root: + +```bash +make +./bin/hayduk +``` + +`make` installs UI dependencies, builds and embeds the UI, and compiles `bin/hayduk`. + +### Development + +After the initial build, run the UI server in one terminal: + +```bash +cd ui +npm run dev +``` + +In a second terminal at the repository root: -Team mode is Hayduk's team server. It requires an explicit bind on a -specific, non-loopback interface address — the one from `ip addr` that -operators can reach; wildcard binds are refused because the printed link -must carry a usable host. Every operator opens the printed token link, picks -a name, and that name rides on their commands and lands next to their -actions in the shared event log. Authentication uses the token URL. Treat -the link like a password and only run team mode on networks you trust. +```bash +make dev +``` -## Testing +Open the URL printed by Hayduk. UI changes reload through the development proxy. - make test # go test ./... + ui tests - make integration # against the docker stack above +### Checks -## Development +Run these commands from the repository root: -Terminal 1: cd ui && npm run dev -Terminal 2: make dev -Open the URL that Hayduk prints. The UI hot-reloads through the dev proxy. +```bash +make test # Go and UI tests +npm --prefix ui run lint +make # Type-check, build the UI, and compile the binary +make integration # Requires the running Docker lab +``` -Protocol types are generated: `make gen` after touching -`internal/protocol/protocol.go`; `make gen-check` catches drift. +Protocol types are generated. With `tygo` available, run `make gen` after editing `internal/protocol/protocol.go`; `make gen-check` checks for generated type drift. -## Credits +## Credits and license -Design lineage: [Armitage](https://github.com/rsmudge/armitage) by -Raphael Mudge. +Hayduk draws on the graphical attack management workflow established by [Armitage](https://github.com/rsmudge/armitage), created by Raphael Mudge. It connects to Metasploit through [go-msf](https://github.com/jolovicdev/go-msf). -License: MIT +Licensed under [MIT](LICENSE). See [third-party notices](docs/THIRD-PARTY-NOTICES.md) for bundled assets and licenses. diff --git a/cmd/hayduk/main.go b/cmd/hayduk/main.go index 96a757f..48b3067 100644 --- a/cmd/hayduk/main.go +++ b/cmd/hayduk/main.go @@ -17,7 +17,7 @@ import ( "github.com/jolovicdev/hayduk/internal/server" ) -var version = "0.1.3" +var version = "0.1.4" func main() { listen := flag.String("listen", "127.0.0.1:0", "host:port to bind") diff --git a/internal/engine/commands.go b/internal/engine/commands.go index ee24ab5..4955577 100644 --- a/internal/engine/commands.go +++ b/internal/engine/commands.go @@ -15,7 +15,9 @@ func (e *Engine) execCommand(ctx context.Context, operator, method string, param if !knownMethod(method) { return nil, &protocol.ErrorBody{Code: protocol.CodeUnknownMethod, Message: "no such method: " + method} } - rpc := e.connectedRPC() + // the workspace the command starts in rides with its events: switching + // workspaces mid-RPC must not re-attribute the result + rpc, ws := e.dispatchScope() if rpc == nil { return nil, notConnected() } @@ -36,6 +38,16 @@ func (e *Engine) execCommand(ctx context.Context, operator, method string, param if err := con.Write(ctx, p.Command); err != nil { return nil, mapErr(err) } + // Readiness is restored only when a read issued after this write + // reports the console idle (consoleRead checks the generation): + // the buffered prompt must not go out here, or browsers accept + // input while msf still runs the command. + e.mu.Lock() + if e.console != nil { + e.consoleWritePending = true + e.consoleWriteGen++ + } + e.mu.Unlock() return nil, nil case protocol.MethodConsoleTabs: @@ -111,7 +123,7 @@ func (e *Engine) execCommand(ctx context.Context, operator, method string, param if eb := requireModuleRef(p.Type, p.Name); eb != nil { return nil, eb } - return e.moduleExecute(ctx, rpc, operator, p) + return e.moduleExecute(ctx, rpc, operator, ws, p) case protocol.MethodSessionAttach: var p protocol.SessionRefParams @@ -173,7 +185,7 @@ func (e *Engine) execCommand(ctx context.Context, operator, method string, param if !validPort(p.LPORT) { return nil, badParam("lport must be between 1 and 65535") } - if eb := e.sessionUpgrade(ctx, operator, p); eb != nil { + if eb := e.sessionUpgrade(ctx, operator, ws, p); eb != nil { return nil, eb } return nil, nil @@ -193,17 +205,18 @@ func (e *Engine) execCommand(ctx context.Context, operator, method string, param if p.Name == "" { return nil, badParam("name is required") } + // refreshMu spans the whole transition including the workspace RPC: + // a periodic refresh that already read the old workspace must not + // commit its stale rows after the clear below, nor interleave its + // old-workspace page reads with the new workspace's + e.refreshMu.Lock() + defer e.refreshMu.Unlock() e.mu.Lock() startGen := e.gen e.mu.Unlock() if err := gomsf.NewDbManager(rpc).SetWorkspace(ctx, p.Name); err != nil { return nil, mapErr(err) } - // refreshMu spans the whole transition: a periodic refresh that - // already read the old workspace must not commit its stale rows - // after the clear below - e.refreshMu.Lock() - defer e.refreshMu.Unlock() // msf already switched: the connection says so and the old // workspace's data must not survive a refresh that fails halfway. // The generation guard keeps a link that was replaced mid-switch @@ -254,12 +267,12 @@ func (e *Engine) execCommand(ctx context.Context, operator, method string, param if eb := parseParams(params, &p); eb != nil { return nil, eb } - return e.hailMary(ctx, operator, p) + return e.hailMary(ctx, operator, ws, p) } return nil, &protocol.ErrorBody{Code: protocol.CodeInternal, Message: "unreachable dispatch for " + method} } -func (e *Engine) moduleExecute(ctx context.Context, rpc gomsf.RPCCaller, operator string, p protocol.ModuleExecuteParams) (json.RawMessage, *protocol.ErrorBody) { +func (e *Engine) moduleExecute(ctx context.Context, rpc gomsf.RPCCaller, operator, ws string, p protocol.ModuleExecuteParams) (json.RawMessage, *protocol.ErrorBody) { if rpc == nil { return nil, notConnected() } @@ -294,15 +307,15 @@ func (e *Engine) moduleExecute(ctx context.Context, rpc gomsf.RPCCaller, operato res, err = mod.Execute(ctx) } if err != nil { - e.eventfOp(operator, protocol.LevelError, "%s/%s failed: %v", p.Type, p.Name, err) + e.eventfOpIn(ws, operator, protocol.LevelError, "%s/%s failed: %v", p.Type, p.Name, err) return nil, mapErr(err) } // msf answers job 0 when the module runs inline and finishes at once - // there is no job to watch then, and saying "job 0" just confuses if res.JobID > 0 { - e.eventfOp(operator, protocol.LevelSuccess, "%s/%s launched as job %d", p.Type, p.Name, res.JobID) + e.eventfOpIn(ws, operator, protocol.LevelSuccess, "%s/%s launched as job %d", p.Type, p.Name, res.JobID) } else { - e.eventfOp(operator, protocol.LevelSuccess, "%s/%s ran inline", p.Type, p.Name) + e.eventfOpIn(ws, operator, protocol.LevelSuccess, "%s/%s ran inline", p.Type, p.Name) } return mustJSON(protocol.ExecPayload{JobID: res.JobID, UUID: res.UUID}), nil } @@ -348,9 +361,19 @@ func (e *Engine) attach(sid string) *protocol.ErrorBody { } previous := e.interactSID e.interactSID = sid - e.interactOut = nil + if previous != sid { + // attachment is idempotent: reopening the already-attached session + // must not erase the transcript every connected operator is reading; + // that output is already consumed from the RPC stream and cannot be + // polled back + e.interactOut = nil + } mon := e.monitor - e.bus.send(protocol.InteractUpdate(&protocol.InteractState{SID: sid})) + // the update carries the buffered transcript: browsers replace their + // local copy on every interact update and would blank it without this + e.bus.send(protocol.InteractUpdate(&protocol.InteractState{ + SID: sid, Output: string(e.interactOut), + })) e.mu.Unlock() if mon != nil { if previous != "" && previous != sid { @@ -400,7 +423,7 @@ func (e *Engine) sessionWrite(ctx context.Context, p protocol.SessionWriteParams return mapErr(gomsf.NewShellSession(rpc, p.SID).Write(ctx, data)) } -func (e *Engine) sessionUpgrade(ctx context.Context, operator string, p protocol.SessionUpgradeParams) *protocol.ErrorBody { +func (e *Engine) sessionUpgrade(ctx context.Context, operator, ws string, p protocol.SessionUpgradeParams) *protocol.ErrorBody { e.mu.Lock() rpc := e.rpc session := e.sessions[p.SID] @@ -417,7 +440,7 @@ func (e *Engine) sessionUpgrade(ctx context.Context, operator string, p protocol if err := gomsf.NewShellSession(rpc, p.SID).Upgrade(ctx, p.LHOST, p.LPORT); err != nil { return mapErr(err) } - e.eventfOp(operator, protocol.LevelSuccess, "session %s upgrading to meterpreter via %s:%d", p.SID, p.LHOST, p.LPORT) + e.eventfOpIn(ws, operator, protocol.LevelSuccess, "session %s upgrading to meterpreter via %s:%d", p.SID, p.LHOST, p.LPORT) return nil } diff --git a/internal/engine/commands_test.go b/internal/engine/commands_test.go index 3829372..8235e74 100644 --- a/internal/engine/commands_test.go +++ b/internal/engine/commands_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sync" + "sync/atomic" "testing" "time" @@ -18,7 +19,7 @@ func TestModuleExecuteAfterDisconnectIsAnErrorNotACrash(t *testing.T) { e := connectedEngine(t, stdFake()) e.Disconnect() - _, eb := e.moduleExecute(context.Background(), e.connectedRPC(), "dana", + _, eb := e.moduleExecute(context.Background(), e.connectedRPC(), "dana", "", protocol.ModuleExecuteParams{Type: "exploit", Name: "windows/smb/a"}) if eb == nil || eb.Code != protocol.CodeNotConnected { t.Fatalf("want not_connected after disconnect, got %+v", eb) @@ -202,3 +203,104 @@ func TestWorkspaceSetSerializesWithInFlightRefresh(t *testing.T) { t.Fatalf("stale rows from the old workspace survived the switch: %d hosts", len(st.Hosts)) } } + +// Reattaching the open session keeps the transcript, and the broadcast +// carries it so browsers keep their copy too. +func TestReattachPreservesTranscript(t *testing.T) { + e := testEngine(t) + e.sessions["1"] = &protocol.SessionState{ID: "1", Type: "shell"} + if err := e.attach("1"); err != nil { + t.Fatal(err) + } + e.sessionOutput(nil, gomsf.Event{SessionID: "1", Data: "shared transcript\n"}) + sub := e.Subscribe() + defer sub.Stop() + if err := e.attach("1"); err != nil { + t.Fatal(err) + } + if got := e.State().Interact.Output; got != "shared transcript\n" { + t.Fatalf("transcript after reattach: %q", got) + } + select { + case m := <-sub.C(): + up, ok := m.(protocol.ResourceUpdate) + if !ok || up.Resource != protocol.ResInteract || up.Interact == nil { + t.Fatalf("reattach broadcast %+v", m) + } + if up.Interact.Output != "shared transcript\n" { + t.Fatalf("reattach broadcast carried %q", up.Interact.Output) + } + case <-time.After(5 * time.Second): + t.Fatal("reattach never broadcast") + } +} + +// workspace.set must not touch the remote workspace while a refresh is +// mid-fetch, and the state after the switch must be the new workspace's. +func TestWorkspaceSwitchWaitsForInFlightRefresh(t *testing.T) { + e := testEngine(t) + f := stdFake() + e.rpc = f + e.conn.Workspace = "alpha" + + var ws atomic.Value + ws.Store("alpha") + setWorkspaceEntered := make(chan struct{}) + var setOnce sync.Once + f.set(gomsf.DbSetWorkspace, func(args ...interface{}) (interface{}, error) { + ws.Store(args[0].(string)) + setOnce.Do(func() { close(setWorkspaceEntered) }) + return map[string]interface{}{"result": "success"}, nil + }) + f.set(gomsf.DbCurrentWorkspace, func(...interface{}) (interface{}, error) { + return map[string]interface{}{"workspace": ws.Load().(string)}, nil + }) + hostRead := make(chan struct{}, 1) + release := make(chan struct{}) + var once sync.Once + f.set(gomsf.DbHosts, func(args ...interface{}) (interface{}, error) { + once.Do(func() { hostRead <- struct{}{} }) + <-release + return map[string]interface{}{"hosts": []interface{}{ + map[string]interface{}{"address": args[0].(map[string]interface{})["workspace"].(string) + "-host"}, + }}, nil + }) + f.set(gomsf.DbServices, func(args ...interface{}) (interface{}, error) { + return map[string]interface{}{"services": []interface{}{ + map[string]interface{}{"host": args[0].(map[string]interface{})["workspace"].(string) + "-host", "port": 80}, + }}, nil + }) + + refreshDone := make(chan error, 1) + go func() { refreshDone <- e.refreshDB(context.Background()) }() + <-hostRead + + switchDone := make(chan struct{}) + go func() { + _, _ = e.Exec(context.Background(), "", protocol.MethodWorkspaceSet, json.RawMessage(`{"name":"beta"}`)) + close(switchDone) + }() + + select { + case <-setWorkspaceEntered: + t.Fatal("workspace.set ran during an in-flight refresh") + case <-time.After(50 * time.Millisecond): + } + + close(release) + if err := <-refreshDone; err != nil { + t.Fatal(err) + } + select { + case <-switchDone: + case <-time.After(5 * time.Second): + t.Fatal("workspace.set never finished") + } + st := e.State() + if st.Connection.Workspace != "beta" { + t.Fatalf("workspace %q", st.Connection.Workspace) + } + if len(st.Hosts) != 1 || st.Hosts[0].Address != "beta-host" { + t.Fatalf("hosts after switch: %+v", st.Hosts) + } +} diff --git a/internal/engine/connect.go b/internal/engine/connect.go index 7baab51..27e3eac 100644 --- a/internal/engine/connect.go +++ b/internal/engine/connect.go @@ -40,7 +40,14 @@ func (e *Engine) Connect(ctx context.Context, p protocol.ConnectParams) *protoco return &protocol.ErrorBody{Code: protocol.CodeBusy, Message: "already connecting or connected"} } e.connecting = true + // connectSeq names this attempt as the slot owner: a bootstrap canceled + // by Disconnect may still be unwinding when the next connect starts, and + // only the current owner may touch connecting/connectCancel + e.connectSeq++ + attempt := e.connectSeq gen := e.gen + attemptCtx, cancelAttempt := context.WithCancel(ctx) + e.connectCancel = cancelAttempt e.conn = protocol.ConnectionState{ Status: "connecting", Host: p.Host, Port: p.Port, SSL: p.SSL, Username: p.Username, @@ -48,10 +55,13 @@ func (e *Engine) Connect(ctx context.Context, p protocol.ConnectParams) *protoco e.bus.send(protocol.ConnectionUpdate(e.conn)) e.mu.Unlock() - err := e.bootstrap(ctx, p, gen) + err := e.bootstrap(attemptCtx, p, gen) e.mu.Lock() - e.connecting = false + if e.connectSeq == attempt { + e.connecting = false + e.connectCancel = nil + } ownsState := e.gen == gen if err != nil { if ownsState { // a disconnect or newer connect already owns the state @@ -60,10 +70,12 @@ func (e *Engine) Connect(ctx context.Context, p protocol.ConnectParams) *protoco e.bus.send(protocol.ConnectionUpdate(e.conn)) } e.mu.Unlock() + cancelAttempt() return &protocol.ErrorBody{Code: protocol.CodeConnectFailed, Message: err.Error()} } if e.gen != gen+1 { // bootstrap committed, then something tore the link down e.mu.Unlock() + cancelAttempt() return &protocol.ErrorBody{Code: protocol.CodeConnectFailed, Message: "connection attempt superseded"} } e.conn.Status = "connected" @@ -76,6 +88,7 @@ func (e *Engine) Connect(ctx context.Context, p protocol.ConnectParams) *protoco // that carries modules and db state to already-connected browsers e.bus.send(protocol.NewSnapshot(e.stateLocked())) e.mu.Unlock() + cancelAttempt() return nil } @@ -145,23 +158,26 @@ func (e *Engine) bootstrap(ctx context.Context, p protocol.ConnectParams, gen ui } db := gomsf.NewDbManager(rpc) - hosts, err := db.Hosts(ctx, nil) + workspace, _ := db.CurrentWorkspace(ctx) // "" when no db; not fatal + // pages are pinned to the workspace read above for the same reason the + // refresh pins its own: a console workspace switch mid-load must not mix + // collections, and msf's unpaged reads truncate at 100 rows + hosts, err := fetchAllPages(ctx, workspace, db.Hosts) if err != nil { return err } - services, err := db.Services(ctx, nil) + services, err := fetchAllPages(ctx, workspace, db.Services) if err != nil { return err } - creds, err := db.Creds(ctx, nil) + creds, err := fetchAllPages(ctx, workspace, db.Creds) if err != nil { return err } - loots, err := db.Loots(ctx, nil) + loots, err := fetchAllPages(ctx, workspace, db.Loots) if err != nil { return err } - workspace, _ := db.CurrentWorkspace(ctx) // "" when no db; not fatal e.eventf(protocol.LevelInfo, "loaded %d modules, reading database", len(modules.Exploits)+len(modules.Auxiliary)+len(modules.Post)+len(modules.Payloads)+len(modules.Encoders)+len(modules.Nops)+len(modules.Evasion)) @@ -181,6 +197,26 @@ func (e *Engine) bootstrap(ctx context.Context, p protocol.ConnectParams, gen ui } e.eventf(protocol.LevelInfo, "console ready, %d hosts and %d services in workspace", len(hosts), len(services)) + // Seed the daemon's live sessions before the monitor's first sync can + // report them as opened: sessions that already existed must not be + // re-tagged with the workspace active at (re)connect. Known + // attributions survive in sessionTags when the uuid proves the session + // is the same one - ids are reused across daemons and restarts. + // Sessions first seen now stay untagged and the report's host-membership + // fallback decides. A failed list is non-fatal: the monitor then picks + // them up tagged with the current workspace. + liveSessions, _ := gomsf.NewSessionManager(rpc).List(ctx) + e.mu.Lock() + prevTags := make(map[string]sessionTag, len(e.sessionTags)) + for sid, tag := range e.sessionTags { + prevTags[sid] = tag + } + e.mu.Unlock() + seedSessions := make(map[string]*protocol.SessionState, len(liveSessions)) + for sid, s := range liveSessions { + seedSessions[sid] = sessionState(sid, s, restoreTag(prevTags[sid], s.UUID)) + } + runCtx, cancel := context.WithCancel(context.Background()) monitor := gomsf.NewEventMonitor(runCtx, rpc, gomsf.WithEventSessionInterval(e.cfg.SessionInterval), @@ -195,7 +231,7 @@ func (e *Engine) bootstrap(ctx context.Context, p protocol.ConnectParams, gen ui e.mu.Lock() if e.gen != gen { e.mu.Unlock() - cancel() // our monitor's ctx; nobody else owns it + cancel() // our monitor's ctx; nobody else owns it return errSuperseded // the defer releases this attempt's consoles } oldCancel := e.runCancel @@ -213,6 +249,7 @@ func (e *Engine) bootstrap(ctx context.Context, p protocol.ConnectParams, gen ui e.consoleID = con.ID e.consoleOut = nil e.consolePrompt = "" + e.consoleWritePending = false e.routeConsole = routeCon e.routes = nil e.conn.MSFVersion = version.Version @@ -223,7 +260,23 @@ func (e *Engine) bootstrap(ctx context.Context, p protocol.ConnectParams, gen ui e.services = serviceStates(services) e.creds = credStates(creds) e.loot = lootStates(loots) - e.sessions = make(map[string]*protocol.SessionState) + e.sessions = seedSessions + // attribution survives the reconnect; tags of sessions that died while + // offline go with them + for sid := range e.sessionTags { + if _, live := seedSessions[sid]; !live { + delete(e.sessionTags, sid) + } + } + for sid, st := range seedSessions { + if st.Workspace == "" && st.UUID == "" { + continue + } + if e.sessionTags == nil { + e.sessionTags = make(map[string]sessionTag) + } + e.sessionTags[sid] = sessionTag{workspace: st.Workspace, uuid: st.UUID} + } e.jobs = make(map[string]*protocol.JobState) e.errStreak = 0 e.gen = gen + 1 @@ -254,6 +307,16 @@ func (e *Engine) bootstrap(ctx context.Context, p protocol.ConnectParams, gen ui func (e *Engine) Disconnect() { e.mu.Lock() e.gen++ // invalidates any bootstrap or refresh still in flight + if e.connecting { + // Free the connection slot at once: the canceled bootstrap unwinds + // on its own schedule and must not lock out a fresh connect. It + // cannot clobber the next attempt either - slot ownership is by + // connectSeq, and the old attempt fails on its canceled context. + if e.connectCancel != nil { + e.connectCancel() + } + e.connecting = false + } cancel := e.runCancel dropRPC := e.rpc dropConsoleID := e.consoleID @@ -267,6 +330,7 @@ func (e *Engine) Disconnect() { e.console = nil e.consoleID = "" e.consolePrompt = "" + e.consoleWritePending = false e.routeConsole = nil hadRoutes := len(e.routes) > 0 e.routes = nil diff --git a/internal/engine/console.go b/internal/engine/console.go index e23b207..5651576 100644 --- a/internal/engine/console.go +++ b/internal/engine/console.go @@ -17,17 +17,21 @@ func (e *Engine) consoleLoop(ctx context.Context, monitor *gomsf.EventMonitor, c case <-ctx.Done(): return case <-ticker.C: + // the generation is captured before the read is issued: a read + // that was already in flight when a write landed describes the + // old console and must not acknowledge the write's completion + gen := e.consoleGeneration() result, err := console.Read(ctx) if err != nil { e.monitorError(monitor, err) continue } - e.consoleRead(console, result) + e.consoleRead(console, result, gen) } } } -func (e *Engine) consoleRead(console *gomsf.MsfConsole, result *gomsf.ConsoleReadResult) { +func (e *Engine) consoleRead(console *gomsf.MsfConsole, result *gomsf.ConsoleReadResult, readGen uint64) { data := cleanOutput(result.Data) prompt := cleanOutput(result.Prompt) @@ -46,7 +50,11 @@ func (e *Engine) consoleRead(console *gomsf.MsfConsole, result *gomsf.ConsoleRea if data != "" { e.consoleOut = appendCapped(e.consoleOut, []byte(data)) } - if !result.Busy && prompt != "" && !bytes.HasSuffix(e.consoleOut, []byte(prompt)) { + // a stale read - issued before a still-pending write - must not append + // or emit the prompt: every prompt-terminated message reads as ready in + // browsers, whatever path it takes + staleRead := e.consoleWritePending && readGen != e.consoleWriteGen + if !result.Busy && prompt != "" && !staleRead && !bytes.HasSuffix(e.consoleOut, []byte(prompt)) { e.consoleOut = appendCapped(e.consoleOut, []byte(prompt)) stream += prompt } @@ -57,5 +65,13 @@ func (e *Engine) consoleRead(console *gomsf.MsfConsole, result *gomsf.ConsoleRea } else if stream != "" { e.bus.send(protocol.ConsoleOutputMsg{Type: protocol.KindConsoleOutput, Data: stream}) } + if !result.Busy && e.consoleWritePending && readGen == e.consoleWriteGen { + // a read issued after the write reports the console idle: browsers + // that cleared their local prompt on send get the full buffer back. + // A silent command produces no output and no prompt change, so this + // is the only path that restores them. + e.consoleWritePending = false + e.bus.send(protocol.ConsoleUpdate(&protocol.ConsoleState{Output: output})) + } e.mu.Unlock() } diff --git a/internal/engine/disconnect_test.go b/internal/engine/disconnect_test.go index 274e16e..e78d87b 100644 --- a/internal/engine/disconnect_test.go +++ b/internal/engine/disconnect_test.go @@ -1,6 +1,9 @@ package engine import ( + "context" + "errors" + "sync/atomic" "testing" "time" @@ -119,3 +122,31 @@ func TestStaleMonitorEventsDoNotResurrectClearedState(t *testing.T) { t.Fatalf("stale monitor event resurrected job: %+v", st.Jobs) } } + +// Disconnect frees the connection slot while the old bootstrap unwinds. +func TestDisconnectAllowsFreshConnect(t *testing.T) { + f := stdFake() + entered := make(chan struct{}) + release := make(chan struct{}) + var calls int32 + f.set(gomsf.CoreVersion, func(...interface{}) (interface{}, error) { + if atomic.AddInt32(&calls, 1) == 1 { + close(entered) + <-release + return nil, errors.New("old connection failed") + } + return map[string]interface{}{"version": "6.5.2"}, nil + }) + e := New(Config{RPC: f}) + t.Cleanup(e.Shutdown) + done := make(chan struct{}) + go func() { e.Connect(context.Background(), protocol.ConnectParams{Host: "old"}); close(done) }() + <-entered + e.Disconnect() + err := e.Connect(context.Background(), protocol.ConnectParams{Host: "new"}) + close(release) + <-done + if err != nil { + t.Fatalf("fresh connect after disconnect rejected: %+v", err) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 44ae014..49d4122 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -79,14 +79,32 @@ type Engine struct { moduleRanks map[string]string consoleOut []byte consolePrompt string - interactSID string - interactOut []byte + // consoleWritePending marks a written command whose completion no read + // has confirmed yet: readiness is restored to browsers only after a read + // reports the console idle, never straight off the write. Reads and + // writes race on the daemon, so consoleWriteGen separates reads issued + // before a write (which describe the old console) from reads issued + // after it - only the latter may acknowledge completion. + consoleWritePending bool + consoleWriteGen uint64 + interactSID string + interactOut []byte + // sessionTags remembers the workspace each live session was opened + // under, keyed by session id and validated by uuid: ids are per-daemon + // counters and get reused, so attribution carries across a reconnect + // only when the session is the same one. Survives disconnects. + sessionTags map[string]sessionTag events []*protocol.EventEntry operators map[string]int seq int64 errStreak int connecting bool + // connectSeq names the connection attempt that owns the connecting slot; + // connectCancel kills a bootstrap still unwinding after Disconnect freed + // the slot for the next attempt. Both guarded by mu. + connectSeq uint64 + connectCancel context.CancelFunc // refreshMu serializes db refreshes: the loop's periodic sweep and // command-driven ones can overlap, and a stalled read committing after // a newer refresh would revert it @@ -262,13 +280,23 @@ func (e *Engine) logf(level, format string, args ...any) { // logfOp is logf with operator attribution (team mode). Callers must hold e.mu. func (e *Engine) logfOp(operator, level, format string, args ...any) { + e.logfIn(e.conn.Workspace, operator, level, format, args...) +} + +// logfIn is logfOp with an explicit originating workspace: a command that +// started in one workspace keeps its events there even when the operator +// switches before the command's RPC returns. Callers must hold e.mu. +func (e *Engine) logfIn(ws, operator, level, format string, args ...any) { e.seq++ entry := &protocol.EventEntry{ Seq: e.seq, Time: time.Now().UTC(), Level: level, Operator: operator, - Text: fmt.Sprintf(format, args...), + // the workspace the event belongs to; empty for framework-wide + // events. Reports filter on it. + Workspace: ws, + Text: fmt.Sprintf(format, args...), } e.events = append(e.events, entry) if len(e.events) > eventRingCap { @@ -288,6 +316,14 @@ func (e *Engine) eventfOp(operator, level, format string, args ...any) { e.mu.Unlock() } +// eventfOpIn is eventfOp with an explicit originating workspace, for command +// results that land after RPC round trips. +func (e *Engine) eventfOpIn(ws, operator, level, format string, args ...any) { + e.mu.Lock() + e.logfIn(ws, operator, level, format, args...) + e.mu.Unlock() +} + // OperatorJoin and OperatorLeave track connected operators (team mode). The // operators resource fires when a name's connection count crosses zero. func (e *Engine) OperatorJoin(name string) { e.operatorDelta(name, 1) } @@ -406,6 +442,40 @@ func (e *Engine) connectedRPC() gomsf.RPCCaller { return e.rpc } +// sessionTag is the remembered attribution of one session id. +type sessionTag struct { + workspace string + uuid string +} + +// restoreTag returns the remembered workspace when the uuid matches the +// live session's. A reused id (different uuid) or a missing uuid on either +// side restores nothing: the host-membership fallback decides. +func restoreTag(tag sessionTag, uuid string) string { + if tag.uuid == "" || uuid == "" || tag.uuid != uuid { + return "" + } + return tag.workspace +} + +// dispatchScope captures the rpc handle together with the workspace a command +// starts in: the workspace rides with the command's events so a result +// landing after a workspace switch cannot be re-attributed to the new one. +func (e *Engine) dispatchScope() (gomsf.RPCCaller, string) { + e.mu.Lock() + defer e.mu.Unlock() + return e.rpc, e.conn.Workspace +} + +// consoleGeneration snapshots the write generation for a read about to be +// issued: the read may acknowledge a pending write only when the generation +// still matches, i.e. no write landed after the read was issued. +func (e *Engine) consoleGeneration() uint64 { + e.mu.Lock() + defer e.mu.Unlock() + return e.consoleWriteGen +} + var ansiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`) func cleanOutput(s string) string { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index e2aa3dc..cbdce08 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -1381,3 +1381,248 @@ func TestRefreshBroadcastsContentChanges(t *testing.T) { case <-time.After(300 * time.Millisecond): } } + +// console.write restores readiness only after a read reports the console +// idle; a silent command would otherwise leave browsers without a prompt. +func TestConsoleWriteRestoresReadinessOnlyAfterIdleRead(t *testing.T) { + e := connectedEngine(t, stdFake()) + sub := e.Subscribe() + defer sub.Stop() + + e.mu.Lock() + con := e.console + e.mu.Unlock() + e.consoleRead(con, &gomsf.ConsoleReadResult{Prompt: "msf > ", Busy: false}, e.consoleGeneration()) + // consume the seed read's consoleOutput first; bus delivery is async + select { + case m := <-sub.C(): + out, ok := m.(protocol.ConsoleOutputMsg) + if !ok || out.Data != "msf > " { + t.Fatalf("seed read broadcast %+v", m) + } + case <-time.After(5 * time.Second): + t.Fatal("seed prompt never arrived") + } + + write := func() { + if _, err := e.Exec(context.Background(), "", protocol.MethodConsoleWrite, + json.RawMessage(`{"command":"silent-command\n"}`)); err != nil { + t.Fatal(err) + } + } + expectQuiet := func() { + select { + case m := <-sub.C(): + t.Fatalf("console.write broadcast %+v before an idle post-write read", m) + default: + } + } + expectRestore := func() { + deadline := time.After(5 * time.Second) + for { + select { + case m := <-sub.C(): + up, ok := m.(protocol.ResourceUpdate) + if ok && up.Resource == protocol.ResConsole && up.Console != nil && + strings.HasSuffix(up.Console.Output, "msf > ") { + return + } + case <-deadline: + t.Fatal("no console update after the idle read") + } + } + } + + write() + expectQuiet() + e.consoleRead(con, &gomsf.ConsoleReadResult{Prompt: "msf > ", Busy: false}, e.consoleGeneration()) + expectRestore() + + // a read issued before the write describes the old console. With + // background output on it, its update must still not carry a prompt: + // browsers would read one as ready while the command runs + staleGen := e.consoleGeneration() + write() + e.consoleRead(con, &gomsf.ConsoleReadResult{Data: "background output\n", Prompt: "msf > ", Busy: false}, staleGen) + deadline := time.After(5 * time.Second) + for { + var sawUpdate bool + for { + select { + case m := <-sub.C(): + switch up := m.(type) { + case protocol.ResourceUpdate: + if up.Resource != protocol.ResConsole || up.Console == nil { + continue + } + sawUpdate = true + if strings.HasSuffix(up.Console.Output, "msf > ") { + t.Fatalf("stale read restored a ready prompt: %q", up.Console.Output) + } + case protocol.ConsoleOutputMsg: + if strings.HasSuffix(up.Data, "msf > ") { + t.Fatalf("stale read streamed a ready prompt: %q", up.Data) + } + } + case <-time.After(200 * time.Millisecond): + goto drained + } + } + drained: + if sawUpdate { + break + } + select { + case <-deadline: + t.Fatal("stale read's background output never arrived") + default: + } + } + e.consoleRead(con, &gomsf.ConsoleReadResult{Prompt: "msf > ", Busy: false}, e.consoleGeneration()) + expectRestore() +} + +// Sessions live in msfrpcd across reconnects: a reconnect under another +// workspace must not re-tag them, and sessions first seen at connect stay +// untagged so the report's host fallback decides. +func TestReconnectKeepsSessionAttribution(t *testing.T) { + f := stdFake() + var daemonSessions atomic.Value // map[string]interface{} + daemonSessions.Store(map[string]interface{}{}) + f.set(gomsf.SessionList, func(args ...interface{}) (interface{}, error) { + return daemonSessions.Load().(interface{}), nil + }) + f.set(gomsf.DbCurrentWorkspace, func(...interface{}) (interface{}, error) { + return map[string]interface{}{"workspace": "client-alpha"}, nil + }) + + e := connectedEngine(t, f) + // sessions 1 and 2 open while client-alpha is active + e.mu.Lock() + mon := e.monitor + e.mu.Unlock() + for _, sid := range []string{"1", "2"} { + e.sessionOpened(mon, gomsf.Event{SessionID: sid, + Session: &gomsf.Session{Type: "shell", UUID: "uuid-" + sid}}) + } + waitFor(t, func() bool { return len(e.State().Sessions) == 2 }) + for _, sid := range []string{"1", "2"} { + if ws := e.State().Sessions[sid].Workspace; ws != "client-alpha" { + t.Fatalf("session %s tagged %q, want client-alpha", sid, ws) + } + } + + // the link drops; session 2 dies, session 9 appears elsewhere, and the + // operator reconnects under another workspace + daemonSessions.Store(map[string]interface{}{ + "1": map[string]interface{}{"type": "shell", "target_host": "10.0.0.1", "uuid": "uuid-1"}, + "9": map[string]interface{}{"type": "meterpreter"}, + }) + f.set(gomsf.DbCurrentWorkspace, func(...interface{}) (interface{}, error) { + return map[string]interface{}{"workspace": "client-beta"}, nil + }) + e.Disconnect() + if err := e.Connect(context.Background(), protocol.ConnectParams{}); err != nil { + t.Fatalf("reconnect: %+v", err) + } + waitFor(t, func() bool { return len(e.State().Sessions) == 2 }) + sessions := e.State().Sessions + if ws := sessions["1"].Workspace; ws != "client-alpha" { + t.Fatalf("reconnect re-tagged session 1 to %q", ws) + } + if ws := sessions["9"].Workspace; ws != "" { + t.Fatalf("reconnect tagged picked-up session 9 as %q", ws) + } +} + +// Session ids are per-daemon counters: attribution carries across a +// reconnect only when the uuid proves the session is the same one. +func TestSessionAttributionValidatesUUID(t *testing.T) { + f := stdFake() + var daemonSessions atomic.Value + daemonSessions.Store(map[string]interface{}{}) + f.set(gomsf.SessionList, func(args ...interface{}) (interface{}, error) { + return daemonSessions.Load().(interface{}), nil + }) + f.set(gomsf.DbCurrentWorkspace, func(...interface{}) (interface{}, error) { + return map[string]interface{}{"workspace": "client-alpha"}, nil + }) + + e := connectedEngine(t, f) + e.mu.Lock() + mon := e.monitor + e.mu.Unlock() + e.sessionOpened(mon, gomsf.Event{SessionID: "1", + Session: &gomsf.Session{Type: "shell", UUID: "uuid-old"}}) + + // same daemon, same session: the uuid matches, attribution restores + daemonSessions.Store(map[string]interface{}{ + "1": map[string]interface{}{"type": "shell", "uuid": "uuid-old"}, + }) + e.Disconnect() + if err := e.Connect(context.Background(), protocol.ConnectParams{}); err != nil { + t.Fatalf("reconnect: %+v", err) + } + if ws := e.State().Sessions["1"].Workspace; ws != "client-alpha" { + t.Fatalf("same-uuid reconnect returned tag %q, want client-alpha", ws) + } + + // another daemon reused the id: different uuid, no attribution + daemonSessions.Store(map[string]interface{}{ + "1": map[string]interface{}{"type": "shell", "uuid": "uuid-new"}, + }) + e.Disconnect() + if err := e.Connect(context.Background(), protocol.ConnectParams{}); err != nil { + t.Fatalf("reconnect to reused id: %+v", err) + } + if ws := e.State().Sessions["1"].Workspace; ws != "" { + t.Fatalf("reused session id inherited another session's workspace %q", ws) + } +} + +// The monitor reports closes only for sessions it observed; sessions seeded +// at connect that die before the monitor's first poll are dropped by the +// reconciler. A session opened after the reconcile snapshot must survive its +// absence from that snapshot. +func TestReconcileSessionsDropsUnlistedSessions(t *testing.T) { + f := stdFake() + var daemonSessions atomic.Value + daemonSessions.Store(map[string]interface{}{ + "1": map[string]interface{}{"type": "shell", "uuid": "uuid-1"}, + }) + var e *Engine + var listCalls int32 + f.set(gomsf.SessionList, func(args ...interface{}) (interface{}, error) { + // second listing is the first reconcile: the daemon has lost + // session 1, and session 7 opens while the snapshot is taken + if atomic.AddInt32(&listCalls, 1) == 2 { + daemonSessions.Store(map[string]interface{}{ + "7": map[string]interface{}{"type": "shell", "uuid": "uuid-7"}, + }) + e.mu.Lock() + mon := e.monitor + e.mu.Unlock() + e.sessionOpened(mon, gomsf.Event{SessionID: "7", + Session: &gomsf.Session{Type: "shell", UUID: "uuid-7"}}) + return map[string]interface{}{}, nil + } + return daemonSessions.Load().(interface{}), nil + }) + e = connectedEngine(t, f) + waitFor(t, func() bool { return len(e.State().Sessions) == 1 }) + + e.reconcileSessions(context.Background()) + sessions := e.State().Sessions + if _, dead := sessions["1"]; dead { + t.Fatal("session that died before the monitor's first poll survived") + } + if _, young := sessions["7"]; !young { + t.Fatal("session opened after the reconcile snapshot was dropped") + } + + // the next sweep sees session 7 listed and keeps it + e.reconcileSessions(context.Background()) + if _, kept := e.State().Sessions["7"]; !kept { + t.Fatal("listed session dropped by reconciliation") + } +} diff --git a/internal/engine/hailmary.go b/internal/engine/hailmary.go index 6cb4228..6ea4ba6 100644 --- a/internal/engine/hailmary.go +++ b/internal/engine/hailmary.go @@ -24,7 +24,7 @@ type hailMaryTarget struct { matches []protocol.AttackMatch } -func (e *Engine) hailMary(ctx context.Context, operator string, p protocol.HailMaryParams) (json.RawMessage, *protocol.ErrorBody) { +func (e *Engine) hailMary(ctx context.Context, operator, ws string, p protocol.HailMaryParams) (json.RawMessage, *protocol.ErrorBody) { if len(p.Hosts) == 0 { return nil, &protocol.ErrorBody{Code: protocol.CodeBadParams, Message: "no hosts given"} } @@ -77,13 +77,13 @@ func (e *Engine) hailMary(ctx context.Context, operator string, p protocol.HailM launched := 0 for _, t := range targets { if len(t.matches) == 0 { - e.eventfOp(operator, protocol.LevelWarn, "hail mary: no matching exploits for %s", t.host.Address) + e.eventfOpIn(ws, operator, protocol.LevelWarn, "hail mary: no matching exploits for %s", t.host.Address) continue } - e.eventfOp(operator, protocol.LevelInfo, "hail mary on %s: launching %d exploits", t.host.Address, len(t.matches)) + e.eventfOpIn(ws, operator, protocol.LevelInfo, "hail mary on %s: launching %d exploits", t.host.Address, len(t.matches)) for _, m := range t.matches { if runCtx.Err() != nil { - e.eventfOp(operator, protocol.LevelWarn, "hail mary aborted after %d launches: connection ended", launched) + e.eventfOpIn(ws, operator, protocol.LevelWarn, "hail mary aborted after %d launches: connection ended", launched) return } options := map[string]interface{}{"RHOSTS": t.host.Address} @@ -92,7 +92,7 @@ func (e *Engine) hailMary(ctx context.Context, operator string, p protocol.HailM if m.Port > 0 { options["RPORT"] = m.Port } - if _, eb := e.moduleExecute(runCtx, e.connectedRPC(), operator, protocol.ModuleExecuteParams{ + if _, eb := e.moduleExecute(runCtx, e.connectedRPC(), operator, ws, protocol.ModuleExecuteParams{ Type: "exploit", Name: m.Name, Options: options, }); eb == nil { @@ -104,7 +104,7 @@ func (e *Engine) hailMary(ctx context.Context, operator string, p protocol.HailM } } } - e.eventfOp(operator, protocol.LevelSuccess, "hail mary finished: %d of %d planned launches", launched, planned) + e.eventfOpIn(ws, operator, protocol.LevelSuccess, "hail mary finished: %d of %d planned launches", launched, planned) }() return mustJSON(protocol.HailMaryPayload{Planned: planned}), nil diff --git a/internal/engine/ingest.go b/internal/engine/ingest.go index 963a277..c9a8199 100644 --- a/internal/engine/ingest.go +++ b/internal/engine/ingest.go @@ -1,6 +1,7 @@ package engine import ( + "context" "strings" "time" @@ -42,10 +43,31 @@ func (e *Engine) sessionOpened(m *gomsf.EventMonitor, ev gomsf.Event) { e.mu.Unlock() return } - s := ev.Session + // a session the monitor reports new belongs to the campaign that + // opened it; later workspace switches must not re-attribute it. + // Sessions seeded by bootstrap (existing at connect) never take this + // path, so they keep their original - or no - attribution. + st := sessionState(ev.SessionID, ev.Session, e.conn.Workspace) + if e.sessionTags == nil { + e.sessionTags = make(map[string]sessionTag) + } + e.sessionTags[ev.SessionID] = sessionTag{workspace: st.Workspace, uuid: st.UUID} + e.sessions[ev.SessionID] = st + sessions := copyMap(e.sessions) + host := hostLabel(st.TargetHost) + e.logf(protocol.LevelSuccess, "session %s opened (%s) on %s via %s", + ev.SessionID, st.Type, host, st.ViaExploit) + e.bus.send(protocol.SessionsUpdate(sessions)) + e.mu.Unlock() +} + +// sessionState maps a daemon session to engine state; ws is the workspace +// the session was opened under, empty when unknown. +func sessionState(id string, s *gomsf.Session, ws string) *protocol.SessionState { st := &protocol.SessionState{ - ID: ev.SessionID, - OpenedAt: time.Now().UTC(), + ID: id, + OpenedAt: time.Now().UTC(), + Workspace: ws, } if s != nil { st.Type = s.Type @@ -58,13 +80,7 @@ func (e *Engine) sessionOpened(m *gomsf.EventMonitor, ev gomsf.Event) { st.SessionHost = s.SessionHost st.UUID = s.UUID } - e.sessions[ev.SessionID] = st - sessions := copyMap(e.sessions) - host := hostLabel(st.TargetHost) - e.logf(protocol.LevelSuccess, "session %s opened (%s) on %s via %s", - ev.SessionID, st.Type, host, st.ViaExploit) - e.bus.send(protocol.SessionsUpdate(sessions)) - e.mu.Unlock() + return st } func (e *Engine) sessionClosed(m *gomsf.EventMonitor, ev gomsf.Event) { @@ -77,7 +93,7 @@ func (e *Engine) sessionClosed(m *gomsf.EventMonitor, ev gomsf.Event) { e.mu.Unlock() return } - delete(e.sessions, ev.SessionID) + e.removeSessionLocked(ev.SessionID) sessions := copyMap(e.sessions) e.logf(protocol.LevelWarn, "session %s closed", ev.SessionID) if e.interactSID == ev.SessionID { @@ -89,6 +105,48 @@ func (e *Engine) sessionClosed(m *gomsf.EventMonitor, ev gomsf.Event) { e.mu.Unlock() } +// reconcileSessions drops sessions the daemon no longer lists. The monitor +// reports closes only for sessions it observed itself, so a session seeded +// by bootstrap that dies before the monitor's first poll would stay visible +// forever. Additions stay the monitor's job: its open events carry +// attribution. +func (e *Engine) reconcileSessions(ctx context.Context) { + rpc := e.connectedRPC() + if rpc == nil { + return + } + // a session opened after this instant may be absent from the snapshot + // legitimately and is left alone this round + snapshotAt := time.Now() + live, err := gomsf.NewSessionManager(rpc).List(ctx) + if err != nil { + return + } + e.mu.Lock() + if e.rpc != rpc { + e.mu.Unlock() + return + } + changed := false + for sid, st := range e.sessions { + if _, listed := live[sid]; !listed && !st.OpenedAt.After(snapshotAt) { + e.removeSessionLocked(sid) + e.logf(protocol.LevelWarn, "session %s closed", sid) + changed = true + } + } + if changed { + e.bus.send(protocol.SessionsUpdate(copyMap(e.sessions))) + } + e.mu.Unlock() +} + +// removeSessionLocked drops a session and its attribution. Callers hold e.mu. +func (e *Engine) removeSessionLocked(sid string) { + delete(e.sessions, sid) + delete(e.sessionTags, sid) +} + func (e *Engine) sessionOutput(m *gomsf.EventMonitor, ev gomsf.Event) { e.mu.Lock() if m != e.monitor { diff --git a/internal/engine/integration_test.go b/internal/engine/integration_test.go index 7688c69..594fbb5 100644 --- a/internal/engine/integration_test.go +++ b/internal/engine/integration_test.go @@ -5,11 +5,13 @@ package engine import ( "context" "encoding/json" + "fmt" "os" "strings" "testing" "time" + "github.com/jolovicdev/go-msf/v2" "github.com/jolovicdev/hayduk/internal/protocol" ) @@ -85,3 +87,233 @@ func TestIntegrationConsoleRoundtrip(t *testing.T) { } } } + +const testWS = "hayduk-integration" + +// seedTestWorkspace fills a fresh workspace with 105 hosts and services: +// one row past the daemon's 100-row default read limit. +func seedTestWorkspace(t *testing.T) *gomsf.Client { + t.Helper() + p := integrationEnv(t) + rpc, err := gomsf.NewClient(p.Password, + gomsf.WithHost(p.Host), gomsf.WithPort(p.Port), + gomsf.WithSSL(p.SSL), gomsf.WithUsername(p.Username)) + if err != nil { + t.Fatalf("seed client login: %v", err) + } + ctx := context.Background() + _, _ = rpc.Call(ctx, gomsf.DbSetWorkspace, "default") + _, _ = rpc.Call(ctx, gomsf.DbDelWorkspace, testWS) + if _, err := rpc.Call(ctx, gomsf.DbAddWorkspace, testWS); err != nil { + t.Fatalf("add workspace: %v", err) + } + for i := 1; i <= 105; i++ { + addr := fmt.Sprintf("192.0.2.%d", i) + if _, err := rpc.Call(ctx, gomsf.DbReportHost, map[string]interface{}{ + "workspace": testWS, "host": addr, "os_name": "integration", + }); err != nil { + t.Fatalf("report_host %s: %v", addr, err) + } + if _, err := rpc.Call(ctx, gomsf.DbReportService, map[string]interface{}{ + "workspace": testWS, "host": addr, "port": 2222, "proto": "tcp", "name": "ssh", + }); err != nil { + t.Fatalf("report_service %s: %v", addr, err) + } + } + return rpc +} + +func dropTestWorkspace(rpc *gomsf.Client) { + ctx := context.Background() + _, _ = rpc.Call(ctx, gomsf.DbSetWorkspace, "default") + _, _ = rpc.Call(ctx, gomsf.DbDelWorkspace, testWS) +} + +func TestIntegrationRefreshFetchesAllPages(t *testing.T) { + seedRPC := seedTestWorkspace(t) + defer dropTestWorkspace(seedRPC) + + // the daemon caps a single unpaged read at 100 rows + raw, err := gomsf.NewDbManager(seedRPC).Hosts(context.Background(), + map[string]interface{}{"workspace": testWS}) + if err != nil { + t.Fatalf("raw hosts read: %v", err) + } + if len(raw) != 100 { + t.Fatalf("raw single read returned %d rows, want the daemon's 100 cap", len(raw)) + } + + e := New(Config{}) + t.Cleanup(e.Shutdown) + if err := e.Connect(context.Background(), integrationEnv(t)); err != nil { + t.Fatalf("connect: %+v", err) + } + if _, err := e.Exec(context.Background(), "", protocol.MethodWorkspaceSet, + json.RawMessage(`{"name":"`+testWS+`"}`)); err != nil { + t.Fatalf("workspace switch: %+v", err) + } + st := e.State() + if len(st.Hosts) != 105 || len(st.Services) != 105 { + t.Fatalf("105 rows seeded, campaign holds %d hosts and %d services", len(st.Hosts), len(st.Services)) + } + found := false + for _, h := range st.Hosts { + if h.Address == "192.0.2.105" { + found = true + } + } + if !found { + t.Fatal("host from the second page missing") + } +} + +func TestIntegrationReportWorkspaceScoped(t *testing.T) { + seedRPC := seedTestWorkspace(t) + defer dropTestWorkspace(seedRPC) + + e := New(Config{}) + t.Cleanup(e.Shutdown) + if err := e.Connect(context.Background(), integrationEnv(t)); err != nil { + t.Fatalf("connect: %+v", err) + } + + if _, err := e.Exec(context.Background(), "integration-op", protocol.MethodWorkspaceSet, + json.RawMessage(`{"name":"`+testWS+`"}`)); err != nil { + t.Fatalf("switch: %+v", err) + } + var payload protocol.ReportPayload + raw, err := e.Exec(context.Background(), "", protocol.MethodReportHTML, nil) + if err != nil { + t.Fatalf("report in %s: %+v", testWS, err) + } + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + if !strings.Contains(payload.HTML, "192.0.2.105") { + t.Fatal("report missing the second-page host") + } + if !strings.Contains(payload.HTML, "integration-op") { + t.Fatal("report missing operator attribution") + } + + if _, err := e.Exec(context.Background(), "", protocol.MethodWorkspaceSet, + json.RawMessage(`{"name":"default"}`)); err != nil { + t.Fatalf("switch back: %+v", err) + } + raw, err = e.Exec(context.Background(), "", protocol.MethodReportHTML, nil) + if err != nil { + t.Fatalf("report in default: %+v", err) + } + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + if strings.Contains(payload.HTML, "192.0.2.105") || strings.Contains(payload.HTML, "integration-op") { + t.Fatal("default report contains test workspace data") + } +} + +func TestIntegrationConsoleWriteAnswersConsoleState(t *testing.T) { + e := New(Config{}) + t.Cleanup(e.Shutdown) + sub := e.Subscribe() + defer sub.Stop() + if err := e.Connect(context.Background(), integrationEnv(t)); err != nil { + t.Fatalf("connect: %+v", err) + } + + if _, err := e.Exec(context.Background(), "", protocol.MethodConsoleWrite, + json.RawMessage(`{"command":"\n"}`)); err != nil { + t.Fatalf("write: %+v", err) + } + deadline := time.After(5 * time.Second) + for { + select { + case m := <-sub.C(): + up, ok := m.(protocol.ResourceUpdate) + if ok && up.Resource == protocol.ResConsole { + return + } + case <-deadline: + t.Fatal("no console update after the write") + } + } +} + +// Requires the lab's sshbox (msfadmin/msfadmin on port 2222) via +// MSF_SSH_TARGET. +func TestIntegrationSessionReattachAndWriteGuard(t *testing.T) { + target := os.Getenv("MSF_SSH_TARGET") + if target == "" { + t.Skip("MSF_SSH_TARGET not set") + } + e := New(Config{}) + t.Cleanup(e.Shutdown) + if err := e.Connect(context.Background(), integrationEnv(t)); err != nil { + t.Fatalf("connect: %+v", err) + } + + _, eb := e.Exec(context.Background(), "integration-op", protocol.MethodModuleExecute, json.RawMessage(`{ + "type":"auxiliary","name":"scanner/ssh/ssh_login", + "options":{"RHOSTS":"`+target+`","RPORT":2222,"USERNAME":"msfadmin","PASSWORD":"msfadmin","STOP_ON_SUCCESS":true} + }`)) + if eb != nil { + t.Fatalf("ssh_login launch: %+v", eb) + } + var sid string + deadline := time.After(90 * time.Second) + for sid == "" { + for s := range e.State().Sessions { + sid = s + } + if sid != "" { + break + } + select { + case <-deadline: + t.Fatal("ssh_login never opened a session") + case <-time.After(200 * time.Millisecond): + } + } + + attach := func() { + if _, eb := e.Exec(context.Background(), "", protocol.MethodSessionAttach, + json.RawMessage(`{"sid":"`+sid+`"}`)); eb != nil { + t.Fatalf("attach: %+v", eb) + } + } + attach() + if _, eb := e.Exec(context.Background(), "", protocol.MethodSessionWrite, + json.RawMessage(`{"sid":"`+sid+`","data":"echo integration-reattach\n"}`)); eb != nil { + t.Fatalf("session write: %+v", eb) + } + deadline = time.After(30 * time.Second) + for { + if strings.Contains(e.State().Interact.Output, "integration-reattach") { + break + } + select { + case <-deadline: + t.Fatalf("session output never arrived; transcript: %q", e.State().Interact.Output) + case <-time.After(100 * time.Millisecond): + } + } + + attach() + if !strings.Contains(e.State().Interact.Output, "integration-reattach") { + t.Fatalf("reattach cleared the transcript: %q", e.State().Interact.Output) + } + + if _, eb := e.Exec(context.Background(), "", protocol.MethodSessionDetach, nil); eb != nil { + t.Fatalf("detach: %+v", eb) + } + _, eb = e.Exec(context.Background(), "", protocol.MethodSessionWrite, + json.RawMessage(`{"sid":"`+sid+`","data":"echo must-not-run\n"}`)) + if eb == nil || eb.Code != protocol.CodeBusy { + t.Fatalf("write after detach: got %+v, want busy", eb) + } + + if _, eb := e.Exec(context.Background(), "", protocol.MethodSessionStop, + json.RawMessage(`{"sid":"`+sid+`"}`)); eb != nil { + t.Fatalf("stop session: %+v", eb) + } +} diff --git a/internal/engine/ranks.go b/internal/engine/ranks.go index 1fc9bf6..e9552b8 100644 --- a/internal/engine/ranks.go +++ b/internal/engine/ranks.go @@ -26,6 +26,13 @@ type rankTarget struct { modType gomsf.ModuleType } +// rankKey namespaces a rank-cache entry by module type: the exploit and +// auxiliary catalogs can contain the same relative refname, and the bare +// name would collapse both into one entry. +func rankKey(modType gomsf.ModuleType, name string) string { + return string(modType) + "/" + name +} + func (e *Engine) rankTargets() []rankTarget { e.mu.Lock() defer e.mu.Unlock() @@ -47,12 +54,13 @@ func (e *Engine) rankTargets() []rankTarget { } func (e *Engine) rankPrefetch(ctx context.Context, rpc gomsf.RPCCaller) { - // snapshot the cached names: the live map keeps mutating under flush, + // snapshot the cached keys: the live map keeps mutating under flush, // and reading it here without the lock would race cached := make(map[string]bool) e.mu.Lock() - for name := range e.moduleRanks { - cached[name] = true + gen := e.gen + for key := range e.moduleRanks { + cached[key] = true } e.mu.Unlock() @@ -70,6 +78,12 @@ func (e *Engine) rankPrefetch(ctx context.Context, rpc gomsf.RPCCaller) { batch = map[string]string{} mu.Unlock() e.mu.Lock() + if e.gen != gen { + // the link this crawl belongs to was torn down or replaced; + // committing would clobber the newer connection's cache + e.mu.Unlock() + return + } if e.moduleRanks == nil { e.moduleRanks = make(map[string]string, len(cached)+len(out)) } @@ -95,7 +109,7 @@ func (e *Engine) rankPrefetch(ctx context.Context, rpc gomsf.RPCCaller) { continue // uncached; a later connect retries } mu.Lock() - batch[t.name] = info.Rank + batch[rankKey(t.modType, t.name)] = info.Rank full := len(batch) >= rankFlushSize mu.Unlock() if full { @@ -114,7 +128,7 @@ func (e *Engine) rankPrefetch(ctx context.Context, rpc gomsf.RPCCaller) { if ctx.Err() != nil { break } - if cached[t.name] { + if cached[rankKey(t.modType, t.name)] { continue } select { diff --git a/internal/engine/ranks_test.go b/internal/engine/ranks_test.go index a849594..d84bc7a 100644 --- a/internal/engine/ranks_test.go +++ b/internal/engine/ranks_test.go @@ -35,7 +35,7 @@ func TestRankPrefetchBatchesAndCaches(t *testing.T) { waitFor(t, func() bool { return len(e.State().ModuleRanks) == 5 }) ranks := e.State().ModuleRanks - if ranks["windows/smb/a"] != "excellent" || ranks["multi/http/c"] != "normal" { + if ranks["exploit/windows/smb/a"] != "excellent" || ranks["exploit/multi/http/c"] != "normal" { t.Fatalf("ranks %+v", ranks) } @@ -59,3 +59,50 @@ func TestRankPrefetchBatchesAndCaches(t *testing.T) { } waitFor(t, func() bool { return len(e.State().ModuleRanks) == 5 }) } + +// Rank keys are namespaced by module type: exploit and auxiliary catalogs +// can contain the same refname. +func TestRankCacheSeparatesModuleTypes(t *testing.T) { + e := testEngine(t) + e.modules = &protocol.ModuleIndex{Exploits: []string{"test/shared"}, Auxiliary: []string{"test/shared"}} + f := stdFake() + f.set(gomsf.ModuleInfo, func(args ...interface{}) (interface{}, error) { + rank := "manual" + if args[0].(string) == "exploit" { + rank = "excellent" + } + return map[string]interface{}{"name": args[1], "rank": rank}, nil + }) + e.rankPrefetch(context.Background(), f) + got := e.State().ModuleRanks + if len(got) != 2 || got["exploit/test/shared"] != "excellent" || got["auxiliary/test/shared"] != "manual" { + t.Fatalf("ranks %+v, want separate per-type entries", got) + } +} + +// A stale prefetch must not commit over the replacement connection's cache. +func TestCancelledRankPrefetchCannotCommit(t *testing.T) { + e := testEngine(t) + e.modules = &protocol.ModuleIndex{Exploits: []string{"test/stale"}} + f := stdFake() + entered, resume := make(chan struct{}), make(chan struct{}) + f.set(gomsf.ModuleInfo, func(...interface{}) (interface{}, error) { + close(entered) + <-resume + return map[string]interface{}{"name": "stale", "rank": "manual"}, nil + }) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { e.rankPrefetch(ctx, f); close(done) }() + <-entered + cancel() + e.mu.Lock() + e.gen++ + e.moduleRanks = map[string]string{"exploit/test/stale": "excellent"} + e.mu.Unlock() + close(resume) + <-done + if got := e.State().ModuleRanks["exploit/test/stale"]; got != "excellent" { + t.Fatalf("stale prefetch overwrote the rank: got %q", got) + } +} diff --git a/internal/engine/refresh.go b/internal/engine/refresh.go index c0cdc3d..0226297 100644 --- a/internal/engine/refresh.go +++ b/internal/engine/refresh.go @@ -3,6 +3,7 @@ package engine import ( "context" "errors" + "fmt" "sort" "strconv" "strings" @@ -17,12 +18,14 @@ import ( // a newer connection's context. func (e *Engine) refreshLoop(ctx context.Context) { e.refreshDB(ctx) + e.reconcileSessions(ctx) for { select { case <-ctx.Done(): return case <-time.After(e.cfg.RefreshInterval): e.refreshDB(ctx) + e.reconcileSessions(ctx) } } } @@ -44,6 +47,40 @@ func (e *Engine) refreshDB(ctx context.Context) error { return e.refreshDBLocked(ctx) } +const ( + // dbPageSize bounds one db.* RPC read. msf caps unpaged reads at its own + // default limit of 100 rows, so one naked call silently truncates every + // collection of a larger workspace. + dbPageSize = 200 + // dbMaxPages bounds the page walk so a daemon answering every page full + // (or ignoring offset entirely) cannot loop forever: 200 x 500 = 100k + // rows per collection. + dbMaxPages = 500 +) + +// fetchAllPages walks a db collection's limit/offset pages until a short page +// arrives, pinning the workspace into every request. All pages of one +// collection are fetched or none are committed: callers get an error, never a +// silently partial collection. +func fetchAllPages[T any](ctx context.Context, workspace string, fetch func(ctx context.Context, opts map[string]interface{}) ([]*T, error)) ([]*T, error) { + var out []*T + for page := 0; page < dbMaxPages; page++ { + rows, err := fetch(ctx, map[string]interface{}{ + "workspace": workspace, + "limit": dbPageSize, + "offset": page * dbPageSize, + }) + if err != nil { + return nil, err + } + out = append(out, rows...) + if len(rows) < dbPageSize { + return out, nil + } + } + return nil, fmt.Errorf("db collection exceeds %d rows", dbMaxPages*dbPageSize) +} + // refreshDBLocked is refreshDB for callers already holding refreshMu - // workspace.set uses it so its clear-plus-reload transition cannot be // interleaved with a periodic refresh that still read the old workspace. @@ -51,33 +88,40 @@ func (e *Engine) refreshDBLocked(ctx context.Context) error { e.mu.Lock() rpc := e.rpc gen := e.gen + pinned := e.conn.Workspace e.mu.Unlock() if rpc == nil { return errNotConnected } db := gomsf.NewDbManager(rpc) - hosts, err := db.Hosts(ctx, nil) + // The workspace is read once and pinned into every collection page: an + // operator switching workspaces in the raw msf console mid-refresh must + // not mix one refresh's rows across two workspaces. + if current, _ := db.CurrentWorkspace(ctx); current != "" { + pinned = current + } + hosts, err := fetchAllPages(ctx, pinned, db.Hosts) if err != nil { - e.eventf(protocol.LevelWarn, "db refresh failed: %v", err) + e.eventfOpIn(pinned, "", protocol.LevelWarn, "db refresh failed: %v", err) return err } - services, err := db.Services(ctx, nil) + services, err := fetchAllPages(ctx, pinned, db.Services) if err != nil { - e.eventf(protocol.LevelWarn, "db refresh failed: %v", err) + e.eventfOpIn(pinned, "", protocol.LevelWarn, "db refresh failed: %v", err) return err } - creds, err := db.Creds(ctx, nil) + creds, err := fetchAllPages(ctx, pinned, db.Creds) if err != nil { - e.eventf(protocol.LevelWarn, "db refresh failed: %v", err) + e.eventfOpIn(pinned, "", protocol.LevelWarn, "db refresh failed: %v", err) return err } - loots, err := db.Loots(ctx, nil) + loots, err := fetchAllPages(ctx, pinned, db.Loots) if err != nil { - e.eventf(protocol.LevelWarn, "db refresh failed: %v", err) + e.eventfOpIn(pinned, "", protocol.LevelWarn, "db refresh failed: %v", err) return err } - workspace, _ := db.CurrentWorkspace(ctx) + workspace := pinned newHosts := hostStates(hosts) newServices := serviceStates(services) @@ -119,11 +163,13 @@ func (e *Engine) refreshDBLocked(ctx context.Context) error { e.conn.Workspace = workspace e.logf(protocol.LevelInfo, "workspace changed to %s", workspace) } + // discovered rows belong to the workspace this refresh pinned, not to + // whatever the connection says by the time the commit lands for _, h := range discovered { - e.logf(protocol.LevelInfo, "discovered host %s (%s)", h.Address, hostLabel(h.Name)) + e.logfIn(workspace, "", protocol.LevelInfo, "discovered host %s (%s)", h.Address, hostLabel(h.Name)) } if credDelta > 0 { - e.logf(protocol.LevelSuccess, "%d new credentials", credDelta) + e.logfIn(workspace, "", protocol.LevelSuccess, "%d new credentials", credDelta) } if wsChanged { e.bus.send(protocol.ConnectionUpdate(e.conn)) diff --git a/internal/engine/refresh_test.go b/internal/engine/refresh_test.go index 04c82bc..c427226 100644 --- a/internal/engine/refresh_test.go +++ b/internal/engine/refresh_test.go @@ -3,7 +3,10 @@ package engine import ( "context" "errors" + "fmt" "testing" + + "github.com/jolovicdev/go-msf/v2" ) // The error streak counts consecutive failures; a successful db round trip @@ -28,3 +31,43 @@ func TestSuccessfulRefreshClearsMonitorErrorStreak(t *testing.T) { t.Fatalf("a successful db round trip must clear the error streak, got %d", streak) } } + +// msf caps unpaged db.* reads at 100 rows; the refresh walks the pages. +func TestRefreshFetchesAllDatabasePages(t *testing.T) { + e := testEngine(t) + f := stdFake() + e.rpc = f + for _, table := range []struct { + method gomsf.MsfRpcMethod + key string + }{ + {gomsf.DbHosts, "hosts"}, {gomsf.DbServices, "services"}, {gomsf.DbCreds, "creds"}, {gomsf.DbLoots, "loots"}, + } { + f.set(table.method, func(args ...interface{}) (interface{}, error) { + opts := args[0].(map[string]interface{}) + limit, offset := 100, 0 + if v, ok := opts["limit"].(int); ok { + limit = v + } + if v, ok := opts["offset"].(int); ok { + offset = v + } + rows := []interface{}{} + for i := offset; i < 105 && i < offset+limit; i++ { + rows = append(rows, map[string]interface{}{ + "address": fmt.Sprintf("10.0.0.%d", i+1), "host": "10.0.0.1", "name": fmt.Sprintf("row-%d", i), + }) + } + return map[string]interface{}{table.key: rows}, nil + }) + } + if err := e.refreshDB(context.Background()); err != nil { + t.Fatal(err) + } + s := e.State() + for name, n := range map[string]int{"hosts": len(s.Hosts), "services": len(s.Services), "creds": len(s.Creds), "loot": len(s.Loot)} { + if n != 105 { + t.Errorf("%s: 105 rows in the database, %d in the campaign", name, n) + } + } +} diff --git a/internal/engine/report.go b/internal/engine/report.go index b8ea5c2..6ea6fb5 100644 --- a/internal/engine/report.go +++ b/internal/engine/report.go @@ -132,10 +132,11 @@ var reportTmpl = template.Must(template.New("report").Parse(`

Event log

{{if .Events}} - + {{range .Events}} + {{end}}
TimeEvent
TimeOperatorEvent
{{.Time}} {{.Level}}{{.Operator}} {{.Text}}
{{else}}

No events recorded.

{{end}} @@ -177,19 +178,19 @@ type reportLoot struct { } type reportEvent struct { - Time, Level, Text string + Time, Level, Operator, Text string } type reportData struct { - Generated, Workspace, MSFVersion string - HostCount, ServiceCount, SessionCount int - CredCount, LootCount int - Hosts []reportHost - Services []reportService - Sessions []reportSession - Creds []reportCred - Loot []reportLoot - Events []reportEvent + Generated, Workspace, MSFVersion string + HostCount, ServiceCount, SessionCount int + CredCount, LootCount int + Hosts []reportHost + Services []reportService + Sessions []reportSession + Creds []reportCred + Loot []reportLoot + Events []reportEvent } func (e *Engine) reportDocument() (string, *protocol.ErrorBody) { @@ -220,14 +221,14 @@ func (e *Engine) reportDocument() (string, *protocol.ErrorBody) { } data := reportData{ - Generated: time.Now().UTC().Format(time.RFC3339), - Workspace: s.Connection.Workspace, - MSFVersion: s.Connection.MSFVersion, - HostCount: len(s.Hosts), - ServiceCount: len(s.Services), - SessionCount: len(s.Sessions), - CredCount: len(s.Creds), - LootCount: len(s.Loot), + Generated: time.Now().UTC().Format(time.RFC3339), + Workspace: s.Connection.Workspace, + MSFVersion: s.Connection.MSFVersion, + HostCount: len(s.Hosts), + ServiceCount: len(s.Services), + SessionCount: len(s.Sessions), + CredCount: len(s.Creds), + LootCount: len(s.Loot), } for _, h := range s.Hosts { if h == nil { @@ -254,12 +255,34 @@ func (e *Engine) reportDocument() (string, *protocol.ErrorBody) { } return data.Services[i].Port < data.Services[j].Port }) + // Sessions are framework-wide: each is scoped to the workspace it was + // opened under. Sessions with no workspace tag (picked up before any + // attribution) fall back to host membership. + hostSet := make(map[string]bool, len(s.Hosts)) + for _, h := range s.Hosts { + if h != nil { + hostSet[h.Address] = true + } + } sessions := make([]*protocol.SessionState, 0, len(s.Sessions)) for _, v := range s.Sessions { - if v != nil { - sessions = append(sessions, v) + if v == nil { + continue } + switch { + case v.Workspace != "": + if v.Workspace != s.Connection.Workspace { + continue + } + default: + host := firstNonEmpty(v.TargetHost, v.SessionHost) + if host == "" || !hostSet[host] { + continue + } + } + sessions = append(sessions, v) } + data.SessionCount = len(sessions) sort.Slice(sessions, func(i, j int) bool { return sessions[i].ID < sessions[j].ID }) for _, v := range sessions { data.Sessions = append(data.Sessions, reportSession{ @@ -284,7 +307,16 @@ func (e *Engine) reportDocument() (string, *protocol.ErrorBody) { if v == nil { continue } - data.Events = append(data.Events, reportEvent{Time: formatReportTime(v.Time), Level: v.Level, Text: v.Text}) + // events inherit the workspace they were recorded under: one + // client's history must never ride into another client's report. + // Untagged entries are framework-wide and pre-connection ones. + if v.Workspace != "" && v.Workspace != s.Connection.Workspace { + continue + } + data.Events = append(data.Events, reportEvent{ + Time: formatReportTime(v.Time), Level: v.Level, + Operator: v.Operator, Text: v.Text, + }) } var buf bytes.Buffer diff --git a/internal/engine/report_test.go b/internal/engine/report_test.go index 0247b8e..2e96e75 100644 --- a/internal/engine/report_test.go +++ b/internal/engine/report_test.go @@ -1,11 +1,13 @@ package engine import ( + "context" "encoding/json" "strings" "testing" "time" + "github.com/jolovicdev/go-msf/v2" "github.com/jolovicdev/hayduk/internal/protocol" ) @@ -61,3 +63,73 @@ func TestReportCommandWorksDisconnected(t *testing.T) { t.Fatal("empty campaign should still render a document") } } + +func TestReportRetainsOperator(t *testing.T) { + e := testEngine(t) + e.eventfOp("operator-47", protocol.LevelInfo, "campaign event") + doc, err := e.reportDocument() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(doc, "operator-47") { + t.Fatal("report dropped operator attribution") + } +} + +// A report for one workspace excludes another workspace's events. +func TestReportWorkspaceIsolation(t *testing.T) { + f := stdFake() + f.set(gomsf.DbSetWorkspace, func(...interface{}) (interface{}, error) { + return map[string]interface{}{"result": "success"}, nil + }) + f.set(gomsf.DbCurrentWorkspace, func(...interface{}) (interface{}, error) { + return map[string]interface{}{"workspace": "client-beta"}, nil + }) + e := testEngine(t) + e.rpc = f + e.conn.Workspace = "client-alpha" + e.eventf(protocol.LevelInfo, "discovered host CLIENT_ALPHA_PRIVATE_HOST") + if _, err := e.Exec(context.Background(), "", protocol.MethodWorkspaceSet, json.RawMessage(`{"name":"client-beta"}`)); err != nil { + t.Fatal(err) + } + doc, err := e.reportDocument() + if err != nil { + t.Fatal(err) + } + if strings.Contains(doc, "CLIENT_ALPHA_PRIVATE_HOST") { + t.Fatal("client-beta report carries a client-alpha event") + } +} + +// Sessions are framework-wide; the report keeps the ones opened under the +// exported workspace. Untagged sessions fall back to host membership. +func TestReportSessionsScopedToWorkspace(t *testing.T) { + e := testEngine(t) + e.conn.Workspace = "client-beta" + e.hosts = []*protocol.HostState{{Address: "10.0.0.9"}} + e.sessions = map[string]*protocol.SessionState{ + // opened under client-alpha: excluded even though the same host + // exists in both workspaces + "1": {ID: "1", Type: "shell", TargetHost: "10.0.0.9", Workspace: "client-alpha", + Username: "alpha-user", ViaExploit: "exploit/multi/alpha"}, + "2": {ID: "2", Type: "shell", TargetHost: "10.0.0.9", Workspace: "client-beta", + Username: "beta-user"}, + // no workspace tag: host membership decides + "3": {ID: "3", Type: "shell", TargetHost: "10.0.0.9", Username: "legacy-user"}, + "4": {ID: "4", Type: "shell", TargetHost: "10.0.0.1", Username: "outsider"}, + } + doc, err := e.reportDocument() + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"beta-user", "legacy-user"} { + if !strings.Contains(doc, want) { + t.Fatalf("session %s missing from its own workspace report", want) + } + } + for _, leak := range []string{"alpha-user", "exploit/multi/alpha", "outsider"} { + if strings.Contains(doc, leak) { + t.Fatalf("report leaks a session from another workspace: %s", leak) + } + } +} diff --git a/internal/engine/upgrade_test.go b/internal/engine/upgrade_test.go index feeb977..9467a11 100644 --- a/internal/engine/upgrade_test.go +++ b/internal/engine/upgrade_test.go @@ -33,7 +33,7 @@ func TestSessionUpgrade(t *testing.T) { } waitFor(t, func() bool { return len(e.State().Sessions) == 2 }) - if eb := e.sessionUpgrade(context.Background(), "dana", protocol.SessionUpgradeParams{SID: "1", LHOST: "10.0.0.99", LPORT: 4444}); eb != nil { + if eb := e.sessionUpgrade(context.Background(), "dana", "", protocol.SessionUpgradeParams{SID: "1", LHOST: "10.0.0.99", LPORT: 4444}); eb != nil { t.Fatalf("upgrade: %+v", eb) } select { @@ -46,10 +46,10 @@ func TestSessionUpgrade(t *testing.T) { } waitEvent(t, sub, "session 1 upgrading to meterpreter via 10.0.0.99:4444") - if eb := e.sessionUpgrade(context.Background(), "dana", protocol.SessionUpgradeParams{SID: "2", LHOST: "h", LPORT: 1}); eb == nil || eb.Code != protocol.CodeBadParams { + if eb := e.sessionUpgrade(context.Background(), "dana", "", protocol.SessionUpgradeParams{SID: "2", LHOST: "h", LPORT: 1}); eb == nil || eb.Code != protocol.CodeBadParams { t.Fatalf("meterpreter sessions must not upgrade, got %+v", eb) } - if eb := e.sessionUpgrade(context.Background(), "dana", protocol.SessionUpgradeParams{SID: "9", LHOST: "h", LPORT: 1}); eb == nil || eb.Code != protocol.CodeSessionNotFound { + if eb := e.sessionUpgrade(context.Background(), "dana", "", protocol.SessionUpgradeParams{SID: "9", LHOST: "h", LPORT: 1}); eb == nil || eb.Code != protocol.CodeSessionNotFound { t.Fatalf("missing session, got %+v", eb) } } diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index c4c7a9c..b9bcaea 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -131,7 +131,10 @@ type SessionState struct { TargetHost string `json:"targetHost,omitempty"` SessionHost string `json:"sessionHost,omitempty"` UUID string `json:"uuid,omitempty"` - OpenedAt time.Time `json:"openedAt,omitzero"` + // Workspace is the msf workspace active when the session opened; empty + // for sessions picked up without one. Reports scope sessions on it. + Workspace string `json:"workspace,omitempty"` + OpenedAt time.Time `json:"openedAt,omitzero"` } type JobState struct { @@ -190,6 +193,9 @@ type EventEntry struct { Level string `json:"level"` Text string `json:"text"` Operator string `json:"operator,omitempty"` + // Workspace is the msf workspace active when the event was recorded; + // empty for framework-wide events. Reports filter events on it. + Workspace string `json:"workspace,omitempty"` } type CampaignState struct { @@ -360,6 +366,7 @@ const ( MethodSessionUpgrade = "session.upgrade" MethodWorkspaceList = "workspace.list" MethodWorkspaceSet = "workspace.set" + MethodOperatorJoin = "operator.join" MethodDBRefresh = "db.refresh" MethodAttacksFind = "attacks.find" MethodReportHTML = "report.html" @@ -463,7 +470,7 @@ type ExecPayload struct { } type AttackMatch struct { - Name string `json:"name"` + Name string `json:"name"` Reason string `json:"reason"` // Port is the service port the match was struck on; 0 when the matcher // had no port to attribute. Hail Mary forwards it as RPORT so modules diff --git a/internal/server/dist/assets/index-Bd1vBLnh.js b/internal/server/dist/assets/index-Bd1vBLnh.js deleted file mode 100644 index 6febfb8..0000000 --- a/internal/server/dist/assets/index-Bd1vBLnh.js +++ /dev/null @@ -1,4 +0,0 @@ -var e=Object.defineProperty,t=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:`Module`}),r};(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var n={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return r(this.context.count)},getNextContextId(){return r(this.context.count++)}};function r(e){let t=String(e),r=t.length-1;return n.context.id+(r?String.fromCharCode(96+r):``)+t}function i(e){n.context=e}var a=(e,t)=>e===t,o=Symbol(`solid-track`),s={equals:a},c=null,l=ue,u=1,d=2,f={owned:null,cleanups:null,context:null,owner:null},p={},m=null,h=null,g=null,_=null,v=null,y=0;function b(e,t){let n=g,r=m,i=e.length===0,a=t===void 0?r:t,o=i?f:{owned:null,cleanups:null,context:a?a.context:null,owner:a},s=i?e:()=>e(()=>D(()=>F(o)));m=o,g=null;try{return P(s,!0)}finally{g=n,m=r}}function x(e,t){t=t?Object.assign({},s,t):s;let n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0};return[ae.bind(n),e=>(typeof e==`function`&&(e=h&&h.running&&h.sources.has(n)?e(n.tValue):e(n.value)),oe(n,e))]}function S(e,t,n){M(ce(e,t,!0,u))}function C(e,t,n){M(ce(e,t,!1,u))}function w(e,t,n){l=de;let r=ce(e,t,!1,u),i=ie&&re(ie);i&&(r.suspense=i),(!n||!n.render)&&(r.user=!0),v?v.push(r):M(r)}function T(e,t,n){n=n?Object.assign({},s,n):s;let r=ce(e,t,!0,0);return r.observers=null,r.observerSlots=null,r.comparator=n.equals||void 0,M(r),ae.bind(r)}function ee(e){return e&&typeof e==`object`&&`then`in e}function E(e,t,r){let i,a,o;typeof t==`function`?(i=e,a=t,o=r||{}):(i=!0,a=e,o=t||{});let s=null,c=p,l=null,u=!1,d=!1,f=`initialValue`in o,_=typeof i==`function`&&T(i),v=new Set,[y,b]=(o.storage||x)(o.initialValue),[C,w]=x(void 0),[E,te]=x(void 0,{equals:!1}),[O,A]=x(f?`ready`:`unresolved`);m&&k(()=>{for(let e of v.keys())e.decrement();v.clear(),h&&s&&h.promises.delete(s),s=null}),n.context&&(l=n.getNextContextId(),o.ssrLoadFrom===`initial`?c=o.initialValue:n.load&&n.has(l)&&(c=n.load(l)));function j(e,t,n,r){return s===e&&(s=null,r!==void 0&&(f=!0),(e===c||t===c)&&o.onHydrated&&queueMicrotask(()=>o.onHydrated(r,{value:t})),c=p,h&&e&&u?(h.promises.delete(e),u=!1,P(()=>{h.running=!0,ae(t,n)},!1)):ae(t,n)),t}function ae(e,t){P(()=>{t===void 0&&b(()=>e),A(t===void 0?f?`ready`:`unresolved`:`errored`),w(t);for(let e of v.keys())e.decrement();v.clear()},!1)}function oe(){let e=ie&&re(ie),t=y(),n=C();if(n!==void 0&&!s)throw n;return g&&!g.user&&e&&S(()=>{E(),s&&(e.resolved&&h&&u?h.promises.add(s):v.has(e)||(e.increment(),v.add(e)))}),t}function M(e=!0){if(e!==!1&&d)return;d=!1;let t=_?_():i;if(u=h&&h.running,t==null||t===!1){j(s,D(y));return}h&&s&&h.promises.delete(s);let n,r=c===p?D(()=>{try{return a(t,{value:y(),refetching:e})}catch(e){n=e}}):c;if(n!==void 0){j(s,void 0,I(n),t);return}return ee(r)?(s=r,`v`in r?(r.s===1?j(s,r.v,void 0,t):j(s,void 0,I(r.v),t),r):(d=!0,queueMicrotask(()=>d=!1),P(()=>{A(f?`refreshing`:`pending`),te()},!1),r.then(e=>j(r,e,void 0,t),e=>j(r,void 0,I(e),t)))):(j(s,r,void 0,t),r)}Object.defineProperties(oe,{state:{get:()=>O()},error:{get:()=>C()},loading:{get(){let e=O();return e===`pending`||e===`refreshing`}},latest:{get(){if(!f)return oe();let e=C();if(e&&!s)throw e;return y()}}});let se=m;return _?S(()=>(se=m,M(!1))):M(!1),[oe,{refetch:e=>ne(se,()=>M(e)),mutate:b}]}function D(e){if(g===null)return e();let t=g;g=null;try{return e()}finally{g=t}}function te(e,t,n){let r=Array.isArray(e),i,a=n&&n.defer;return n=>{let o;if(r){o=Array(e.length);for(let t=0;tt(o,i,n));return i=o,s}}function O(e){w(()=>D(e))}function k(e){return m===null||(m.cleanups===null?m.cleanups=[e]:m.cleanups.push(e)),e}function ne(e,t){let n=m,r=g;m=e,g=null;try{return P(t,!0)}catch(e){ge(e)}finally{m=n,g=r}}var[A,j]=x(!1);function re(e){let t;return m&&m.context&&(t=m.context[e.id])!==void 0?t:e.defaultValue}var ie;function ae(){let e=h&&h.running;if(this.sources&&(e?this.tState:this.state)){if((e?this.tState:this.state)===u)M(this);else{let e=_;_=null,P(()=>fe(this),!1),_=e}}if(g){let e=this.observers;if(!e||e[e.length-1]!==g){let t=e?e.length:0;g.sources?(g.sources.push(this),g.sourceSlots.push(t)):(g.sources=[this],g.sourceSlots=[t]),e?(e.push(g),this.observerSlots.push(g.sources.length-1)):(this.observers=[g],this.observerSlots=[g.sources.length-1])}}return e&&h.sources.has(this)?this.tValue:this.value}function oe(e,t,n){let r=h&&h.running&&h.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(r,t)){if(h){let r=h.running;(r||!n&&h.sources.has(e))&&(h.sources.add(e),e.tValue=t),r||(e.value=t)}else e.value=t;e.observers&&e.observers.length&&P(()=>{for(let t=0;t1e6)throw _=[],Error()},!1)}return t}function M(e){if(!e.fn)return;F(e);let t=y;se(e,h&&h.running&&h.sources.has(e)?e.tValue:e.value,t),h&&!h.running&&h.sources.has(e)&&queueMicrotask(()=>{P(()=>{h&&(h.running=!0),g=m=e,se(e,e.tValue,t),g=m=null},!1)})}function se(e,t,n){let r,i=m,a=g;g=m=e;try{r=e.fn(t)}catch(t){return e.pure&&(h&&h.running?(e.tState=u,e.tOwned&&e.tOwned.forEach(F),e.tOwned=void 0):(e.state=u,e.owned&&e.owned.forEach(F),e.owned=null)),e.updatedAt=n+1,ge(t)}finally{g=a,m=i}(!e.updatedAt||e.updatedAt<=n)&&(e.updatedAt!=null&&`observers`in e?oe(e,r,!0):h&&h.running&&e.pure?(h.sources.has(e)||(e.value=r),h.sources.add(e),e.tValue=r):e.value=r,e.updatedAt=n)}function ce(e,t,n,r=u,i){let a={fn:e,state:r,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:m,context:m?m.context:null,pure:n};return h&&h.running&&(a.state=0,a.tState=r),m===null||m!==f&&(h&&h.running&&m.pure?m.tOwned?m.tOwned.push(a):m.tOwned=[a]:m.owned?m.owned.push(a):m.owned=[a]),a}function N(e){let t=h&&h.running;if((t?e.tState:e.state)===0)return;if((t?e.tState:e.state)===d)return fe(e);if(e.suspense&&D(e.suspense.inFallback))return e.suspense.effects.push(e);let n=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;r--){if(e=n[r],t){let t=e,i=n[r+1];for(;(t=t.owner)&&t!==i;)if(h.disposed.has(t))return}if((t?e.tState:e.state)===u)M(e);else if((t?e.tState:e.state)===d){let t=_;_=null,P(()=>fe(e,n[0]),!1),_=t}}}function P(e,t){if(_)return e();let n=!1;t||(_=[]),v?n=!0:v=[],y++;try{let t=e();return le(n),t}catch(e){n||(v=null),_=null,ge(e)}}function le(e){if(_&&=(ue(_),null),e)return;let t;if(h){if(!h.promises.size&&!h.queue.size){let e=h.sources,n=h.disposed;v.push.apply(v,h.effects),t=h.resolve;for(let e of v)`tState`in e&&(e.state=e.tState),delete e.tState;h=null,P(()=>{for(let e of n)F(e);for(let t of e){if(t.value=t.tValue,t.owned)for(let e=0,n=t.owned.length;el(n),!1),t&&t()}function ue(e){for(let t=0;t=0;t--)F(e.tOwned[t]);delete e.tOwned}if(h&&h.running&&e.pure)me(e,!0);else if(e.owned){for(t=e.owned.length-1;t>=0;t--)F(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}h&&h.running?e.tState=0:e.state=0}function me(e,t){if(t||(e.tState=0,h.disposed.add(e)),e.owned)for(let t=0;t1?[]:null;return k(()=>ve(a)),()=>{let l=e()||[],u=l.length,d,f;return l[o],D(()=>{let e,t,o,m,h,g,_,v,y;if(u===0)s!==0&&(ve(a),a=[],r=[],i=[],s=0,c&&=[]),n.fallback&&(r=[_e],i[0]=b(e=>(a[0]=e,n.fallback())),s=1);else if(s===0){for(i=Array(u),f=0;f=g&&v>=g&&r[_]===l[v];_--,v--)o[v]=i[_],m[v]=a[_],c&&(h[v]=c[_]);for(e=new Map,t=Array(v+1),f=v;f>=g;f--)y=l[f],d=e.get(y),t[f]=d===void 0?-1:d,e.set(y,f);for(d=g;d<=_;d++)y=r[d],f=e.get(y),f!==void 0&&f!==-1?(o[f]=i[d],m[f]=a[d],c&&(h[f]=c[d]),f=t[f],e.set(y,f)):a[d]();for(f=g;fe(t||{}))}var be=0;function xe(){return n.context?n.getNextContextId():`cl-${be++}`}var Se=e=>`Stale read from <${e}>.`;function R(e){let t=`fallback`in e&&{fallback:()=>e.fallback};return T(ye(()=>e.each,e.children,t||void 0))}function z(e){let t=e.keyed,n=T(()=>e.when,void 0,void 0),r=t?n:T(n,void 0,{equals:(e,t)=>!e==!t});return T(()=>{let i=r();if(i){let a=e.children;return typeof a==`function`&&a.length>0?D(()=>a(t?i:()=>{if(!D(r))throw Se(`Show`);return n()})):a}return e.fallback},void 0,void 0)}var B=e=>T(()=>e());function Ce(e,t,n){let r=n.length,i=t.length,a=r,o=0,s=0,c=t[i-1].nextSibling,l=null;for(;or-s){let i=t[o];for(;s{i=r,t===document?e():G(t,e(),t.firstChild?null:void 0,n)},r.owner),()=>{i(),t.textContent=``}}function V(e,t,n,r){let i,a=()=>{let t=r?document.createElementNS(`http://www.w3.org/1998/Math/MathML`,`template`):document.createElement(`template`);return t.innerHTML=e,n?t.content.firstChild.firstChild:r?t.firstChild:t.content.firstChild},o=t?()=>D(()=>document.importNode(i||=a(),!0)):()=>(i||=a()).cloneNode(!0);return o.cloneNode=o,o}function H(e,t=window.document){let n=t[we]||(t[we]=new Set);for(let r=0,i=e.length;rr.call(e,n[1],t))}else e.addEventListener(t,n,typeof n!=`function`&&n)}function De(e,t,n){if(!t)return n?U(e,`style`):t;let r=e.style;if(typeof t==`string`)return r.cssText=t;typeof n==`string`&&(r.cssText=n=void 0),n||={},t||={};let i,a;for(a in n)t[a]??r.removeProperty(a),delete n[a];for(a in t)i=t[a],i!==n[a]&&(r.setProperty(a,i),n[a]=i);return n}function Oe(e,t,n){n==null?e.style.removeProperty(t):e.style.setProperty(t,n)}function ke(e,t,n){return D(()=>e(t,n))}function G(e,t,n,r){if(n!==void 0&&!r&&(r=[]),typeof t!=`function`)return Me(e,t,r,n);C(r=>Me(e,t(),r,n),r)}function Ae(e){return!!n.context&&!n.done&&(!e||e.isConnected)}function je(e){if(n.registry&&n.events&&n.events.find(([t,n])=>n===e))return;let t=e.target,r=`$$${e.type}`,i=e.target,a=e.currentTarget,o=t=>Object.defineProperty(e,"target",{configurable:!0,value:t}),s=()=>{let n=t[r];if(n&&!t.disabled){let i=t[`${r}Data`];if(i===void 0?n.call(t,e):n.call(t,i,e),e.cancelBubble)return}return t.host&&typeof t.host!=`string`&&!t.host._$host&&t.contains(e.target)&&o(t.host),!0},c=()=>{for(;s()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),n.registry&&!n.done&&(n.done=_$HY.done=!0),e.composedPath){let n=e.composedPath();o(n[0]);for(let e=0;e{let i=t();for(;typeof i==`function`;)i=i();n=Me(e,i,n,r)}),()=>n;else if(Array.isArray(t)){let o=[],c=n&&Array.isArray(n);if(Ne(o,t,n,i))return C(()=>n=Me(e,o,n,r,!0)),()=>n;if(a){if(!o.length)return n;if(r===void 0)return n=[...e.childNodes];let t=o[0];if(t.parentNode!==e)return n;let i=[t];for(;(t=t.nextSibling)!==r;)i.push(t);return n=i}if(o.length===0){if(n=Fe(e,n,r),s)return n}else c?n.length===0?Pe(e,o,r):Ce(e,n,o):(n&&Fe(e),Pe(e,o));n=o}else if(t.nodeType){if(a&&t.parentNode)return n=s?[t]:t;if(Array.isArray(n)){if(s)return n=Fe(e,n,r,t);Fe(e,n,null,t)}else n==null||n===``||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);n=t}return n}function Ne(e,t,n,r){let i=!1;for(let a=0,o=t.length;a=0;a--){let o=t[a];if(i!==o){let t=o.parentNode===e;!r&&!a?t?e.replaceChild(i,o):e.insertBefore(i,n):t&&o.remove()}else r=!0}}else e.insertBefore(i,n);return[i]}var Ie=V(``),Le=V(`<svg viewBox="0 0 24 24"><rect x=2 y=2 width=20 height=20 rx=5 fill=#c9404a></rect><g fill=#f4f6f9><rect x=7.2 y=6.8 width=2.6 height=10.4></rect><rect x=14.2 y=6.8 width=2.6 height=10.4></rect><rect x=7.2 y=10.8 width=9.6 height=2.4>`);function Re(e){let t=()=>e.size??24;return(()=>{var n=Le(),r=n.firstChild;return G(n,L(z,{get when(){return e.title},get children(){var t=Ie();return G(t,()=>e.title),t}}),r),C(r=>{var i=t(),a=t(),o=e.title?void 0:`true`,s=e.title?`img`:void 0,c=!e.tile;return i!==r.e&&U(n,`width`,r.e=i),a!==r.t&&U(n,`height`,r.t=a),o!==r.a&&U(n,`aria-hidden`,r.a=o),s!==r.o&&U(n,`role`,r.o=s),c!==r.i&&n.classList.toggle(`mark`,r.i=c),r},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0}),n})()}var K=class extends Error{code;constructor(e,t){super(t),this.code=e}},ze=1;function Be(e=location){return`${e.protocol===`https:`?`wss`:`ws`}://${e.host}/ws`}var Ve={value:``};function He(e){Ve.value=e}function Ue(){return Ve.value}function We(){let e=null,t=1,n=500,r=new Map,i=new Map,a={status:`connecting`};function o(){a.status=`connecting`,e=new WebSocket(Be());let t=!1;e.addEventListener(`open`,()=>{a.status=`open`,n=500,i.get(`open`)?.forEach(e=>e({}))}),e.addEventListener(`message`,n=>{let a;try{a=JSON.parse(n.data)}catch{console.warn(`unparseable ws message`,n.data);return}if(a?.type===`hello`){if(typeof a.proto==`number`&&a.proto!==ze){t=!0,i.get(`proto_mismatch`)?.forEach(e=>e({proto:a.proto,version:a.version}));try{e?.close()}catch{}return}i.get(`hello`)?.forEach(e=>e(a));return}if(a?.type===`response`){let e=r.get(a.id);if(r.delete(a.id),!e)return;a.ok?e.resolve(a.data):e.reject(new K(a.error?.code??`internal`,a.error?.message??``));return}a?.type?i.get(a.type)?.forEach(e=>e(a)):console.warn(`ws message without type`,a)}),e.addEventListener(`close`,()=>{a.status=`closed`;let e=new K(`disconnected`,`connection to hayduk lost`);for(let t of r.values())t.reject(e);r.clear(),i.get(`closed`)?.forEach(e=>e({})),t||(setTimeout(o,n),n=Math.min(n*2,8e3))})}return o(),{status:()=>a.status,command(n,i){let a=t++;return new Promise((t,o)=>{if(!e||e.readyState!==1){o(new K(`disconnected`,`connection to hayduk is not open`));return}r.set(a,{resolve:t,reject:o});try{e.send(JSON.stringify({type:`command`,id:a,method:n,params:i,...Ve.value?{operator:Ve.value}:{}}))}catch(e){r.delete(a),o(new K(`disconnected`,`send failed: ${e?.message??e}`))}})},on(e,t){let n=i.get(e);return n||(n=new Set,i.set(e,n)),n.add(t),()=>n.delete(t)}}}var Ge=t({ws:()=>q}),q=We(),[Ke,qe]=x([]),[Je,J]=x(!1),Ye=0,Xe=2e3;function Ze(e){Ye&&e.seq!==Ye+1&&J(!0),Ye=e.seq,qe(t=>{let n=[...t,{seq:e.seq,time:e.time||new Date().toISOString(),level:e.level,text:e.text,...e.operator?{operator:e.operator}:{}}];return n.length>Xe?n.slice(n.length-Xe):n})}function Qe(e){Ye=e?.at(-1)?.seq??0,J(!1),qe(e??[])}q.on(`event`,Ze),q.on(`snapshot`,e=>Qe(e.state?.events));var $e={host:`127.0.0.1`,port:55553,ssl:!1,username:`msf`};function et(e){if(!/^\d+$/.test(e.trim()))return;let t=Number(e.trim());return t>=1&&t<=65535?t:void 0}function tt(e=localStorage){let t={...$e};for(let n of Object.keys($e)){let r=e.getItem(`hayduk.`+n);if(r!==null){if(n===`port`){let e=et(r);e!==void 0&&(t[n]=e)}else t[n]=n===`ssl`?r===`true`:r}}return t}var nt=V(`<div style=margin-top:16px;display:grid;gap:8px;text-align:center><p style=margin:0;color:var(--tx0)>Connecting to <b>:</b>…</p><p style="margin:0;font:400 11.5px var(--mono);color:var(--tx2)"></p><p style="margin:6px 0 0;font:400 11px var(--sans);color:var(--tx2)">a cold msfrpcd can take half a minute to answer; the link state is always in the status bar`),rt=V(`<div class="modalback show"><form class=modal style=width:380px><div class=mhead><div><div class=mtitle>Connect to msfrpcd</div><div class=mver>Metasploit operations, mapped.`),it=V(`<div style=margin-top:16px;display:grid;gap:10px><label class=kv><b>Host</b><input></label><div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"><label class=kv><b>Port</b><input type=number></label><label class=kv><b>User</b><input></label></div><label class=kv><b>Password</b><input type=password></label><label style="display:flex;gap:8px;align-items:center;font:400 12.5px var(--sans);color:var(--tx1)"><input type=checkbox>use SSL (msfrpcd without -S)`),at=V(`<p style=color:var(--red-br);margin-top:12px>`),ot=V(`<div class=mbtns><button class=abtn type=submit style="flex:none;padding:0 20px">`);function st(e){let[t,n]=x(tt()),[r,i]=x(``),[a,o]=x(``),[s,c]=x(!1),l=()=>e.conn.status===`connecting`,u=()=>et(String(t().port))!==void 0,d=()=>t().host.trim()!==``&&u()&&r()!==``,f=()=>l()?Ke().at(-1)?.text??`reaching msfrpcd`:``;async function p(n){if(n.preventDefault(),l()||s()||!d())return;c(!0),o(``);let i={...t(),host:t().host.trim(),password:r()};for(let e of Object.keys($e))localStorage.setItem(`hayduk.`+e,String(i[e]));let a;try{await Promise.race([e.onConnect(i),new Promise((e,t)=>{a=window.setTimeout(()=>t(Error(`timeout`)),12e4)})])}catch(e){o(e.message??String(e))}finally{a!==void 0&&window.clearTimeout(a),c(!1)}}return(()=>{var o=rt(),c=o.firstChild,m=c.firstChild,h=m.firstChild;return c.addEventListener(`submit`,p),G(m,L(Re,{size:30}),h),G(c,L(z,{get when(){return l()},get fallback(){return[(()=>{var e=it(),a=e.firstChild,o=a.firstChild.nextSibling,s=a.nextSibling,c=s.firstChild,l=c.firstChild.nextSibling,d=c.nextSibling.firstChild.nextSibling,f=s.nextSibling,p=f.firstChild.nextSibling,m=f.nextSibling.firstChild;return o.$$input=e=>n({...t(),host:e.currentTarget.value}),l.$$input=e=>n({...t(),port:Number(e.currentTarget.value)}),d.$$input=e=>n({...t(),username:e.currentTarget.value}),p.$$input=e=>i(e.currentTarget.value),m.addEventListener(`change`,e=>n({...t(),ssl:e.currentTarget.checked})),C(()=>l.classList.toggle(`invalid`,!u())),C(()=>o.value=t().host),C(()=>l.value=t().port),C(()=>d.value=t().username),C(()=>p.value=r()),C(()=>m.checked=t().ssl),e})(),L(z,{get when(){return e.conn.error||a()},get children(){var t=at();return G(t,()=>e.conn.error||a()),t}}),(()=>{var e=ot(),t=e.firstChild;return G(t,()=>s()?`Connecting…`:`Connect`),C(()=>t.disabled=s()||!d()),e})()]},get children(){var t=nt(),n=t.firstChild,r=n.firstChild.nextSibling,i=r.firstChild,a=n.nextSibling;return G(r,()=>e.conn.host,i),G(r,()=>e.conn.port,null),G(a,f),t}}),null),o})()}H([`input`]);var ct=V(`<div class=ctx id=ctx role=menu>`);function lt(e){return e.replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`)}function ut(e){return e.map(e=>e.sep?`<div class="csep"></div>`:e.head?`<div class="chead"><div class="chn">${lt(e.head)}</div>`+(e.sub?`<div class="chi">${lt(e.sub)}</div>`:``)+`</div>`:`<button class="citem${e.danger?` danger`:``}">`+(e.icon?`<i class="ph ph-${lt(e.icon)}"></i>`:`<i></i>`)+`<span>${lt(e.label??``)}</span>`+(e.hint?`<span class="hint">${lt(e.hint)}</span>`:``)+`</button>`).join(``)}var Y=null;function dt(){Y?.classList.remove(`show`)}function ft(){return Y?.classList.contains(`show`)??!1}function pt(e,t,n,r,i,a){return{x:Math.max(0,Math.min(e,i-n-8)),y:Math.max(0,Math.min(t,a-r-8))}}function mt(e,t){e.preventDefault(),e.stopImmediatePropagation(),ht(e.clientX,e.clientY,t)}function ht(e,t,n){if(!Y)return;Y.innerHTML=ut(n);let r=0,i=n.filter(e=>e.label!==void 0);Y.querySelectorAll(`button.citem`).forEach(e=>{let t=i[r++];t?.fn&&e.addEventListener(`click`,()=>{dt(),t.fn()})}),Y.classList.add(`show`);let a=Y.offsetWidth,o=Y.offsetHeight,s=pt(e,t,a,o,window.innerWidth,window.innerHeight);Y.style.left=s.x+`px`,Y.style.top=s.y+`px`}function gt(){let e;return O(()=>{Y=e;let t=e=>{e.target.closest(`#ctx`)||dt()};document.addEventListener(`click`,t),document.addEventListener(`contextmenu`,t),window.addEventListener(`blur`,dt),window.addEventListener(`resize`,dt),k(()=>{document.removeEventListener(`click`,t),document.removeEventListener(`contextmenu`,t),window.removeEventListener(`blur`,dt),window.removeEventListener(`resize`,dt)})}),(()=>{var t=ct(),n=e;return typeof n==`function`?ke(n,t):e=t,t})()}var _t=V(`<div class="dropdown show"role=menu>`),vt=V(`<div class=menuwrap><button class=mbtn aria-haspopup=true>`),yt=V(`<i>`),bt=V(`<span class=kbd>`),xt=V(`<button><span>`);function St(e){let[t,n]=x(!1),r;return O(()=>{let e=e=>{t()&&r&&!r.contains(e.target)&&n(!1)},i=e=>{e.key===`Escape`&&n(!1)};document.addEventListener(`click`,e),document.addEventListener(`keydown`,i),k(()=>{document.removeEventListener(`click`,e),document.removeEventListener(`keydown`,i)})}),(()=>{var i=vt(),a=i.firstChild,o=r;return typeof o==`function`?ke(o,i):r=i,a.$$click=e=>{e.stopPropagation(),n(!t())},G(a,()=>e.label),G(i,L(z,{get when(){return t()},get children(){var t=_t();return t.$$click=()=>n(!1),G(t,()=>e.children),t}}),null),C(()=>a.classList.toggle(`open`,!!t())),i})()}function X(e){return(()=>{var t=xt(),n=t.firstChild;return t.$$click=()=>e.onClick?.(),G(t,L(z,{get when(){return e.icon},get children(){var t=yt();return C(()=>W(t,`ph ph-${e.icon}`)),t}}),n),G(n,()=>e.label),G(t,L(z,{get when(){return e.hint},get children(){var t=bt();return G(t,()=>e.hint),t}}),null),C(n=>{var r=`ditem${e.danger?` danger`:``}`,i=e.disabled;return r!==n.e&&W(t,n.e=r),i!==n.t&&(t.disabled=n.t=i),n},{e:void 0,t:void 0}),t})()}H([`click`]);function Ct(e){return t=>{t.key===`Escape`&&(e.overlayOpen?.()||e.onClose())}}var wt=V(`<div class="modalback show"><div class=modal role=dialog aria-modal=true tabindex=-1><div class=mhead><div><div class=mtitle></div></div><button class=zbtn aria-label=Close style=margin-left:auto><i class="ph ph-x">`);function Z(e){let t,n=xe();return O(()=>{let n=document.activeElement instanceof HTMLElement?document.activeElement:null;t?.focus(),k(()=>n?.focus());let r=Ct({onClose:e.onClose,overlayOpen:ft});document.addEventListener(`keydown`,r),k(()=>document.removeEventListener(`keydown`,r))}),(()=>{var r=wt(),i=r.firstChild,a=i.firstChild.firstChild,o=a.firstChild,s=a.nextSibling;r.$$click=t=>{t.target===t.currentTarget&&e.onClose()};var c=t;return typeof c==`function`?ke(c,i):t=i,U(i,`aria-labelledby`,n),U(o,`id`,n),G(o,()=>e.title),Ee(s,`click`,e.onClose,!0),G(i,()=>e.children,null),C(t=>De(i,e.width?`width:${e.width}`:``,t)),r})()}H([`click`]);function Tt(e,t,n){return{name:e,path:t,type:n,count:0,children:[]}}var Et={exploits:`exploit`,auxiliary:`auxiliary`,post:`post`,payloads:`payload`,encoders:`encoder`,nops:`nop`,evasion:`evasion`};function Dt(e){let t=Tt(``,``,``);for(let[n,r]of Object.entries(e)){let e=Et[n];if(!e)continue;let i=Tt(n,e,e);for(let t of r??[]){let n=t.split(`/`),r=i;r.count++;for(let i=0;i<n.length;i++)if(i===n.length-1)r.children.push(Tt(n[i],t,e));else{let t=r.children.find(e=>e.name===n[i]&&e.children.length>0);t||(t=Tt(n[i],r.path===``?n[i]:`${r.path}/${n[i]}`,e),r.children.push(t)),t.count++,r=t}}Ot(i),t.children.push(i)}return t.children.sort((e,t)=>e.name.localeCompare(t.name)),t}function Ot(e){e.children.sort((e,t)=>e.name.localeCompare(t.name));for(let t of e.children)Ot(t)}function kt(e,t){return t?e:[]}function At(e,t){let n=new Set;if(!t)return n;let r=t.toLowerCase();function i(e){let t=!1;for(let n of e.children)t=i(n)||t;let a=e.path.toLowerCase().includes(r);return(t||a)&&n.add(e.path),t||a}return i(e),n}function jt(e){switch((e??``).toLowerCase()){case`excellent`:return{label:`excellent`,hot:!0};case`great`:return{label:`great`,hot:!0};case`good`:case`average`:case`low`:case`manual`:return{label:e.toLowerCase(),hot:!1};default:return null}}function Mt(){return{connection:{status:`disconnected`,host:``,port:0,ssl:!1,username:``,msfVersion:``,workspace:``},hosts:[],services:[],sessions:{},jobs:{},routes:[],creds:[],loot:[],moduleRanks:{},operators:[],events:[]}}function Nt(e){return{...Mt(),...e,hosts:e?.hosts??[],services:e?.services??[],sessions:e?.sessions??{},jobs:e?.jobs??{},routes:e?.routes??[],creds:e?.creds??[],loot:e?.loot??[],moduleRanks:e?.moduleRanks??{},operators:e?.operators??[],events:e?.events??[]}}function Pt(e,t){switch(t.resource){case`connection`:e.connection=t.connection;break;case`hosts`:e.hosts=t.hosts??[];break;case`services`:e.services=t.services??[];break;case`sessions`:e.sessions=t.sessions??{};break;case`jobs`:e.jobs=t.jobs??{};break;case`routes`:e.routes=t.routes??[];break;case`creds`:e.creds=t.creds??[];break;case`loot`:e.loot=t.loot??[];break;case`modules`:e.modules=t.modules;break;case`moduleRanks`:e.moduleRanks={...e.moduleRanks??{},...t.moduleRanks??{}};break;case`operators`:e.operators=t.operators??[];break;case`console`:e.console=t.console;break;case`interact`:e.interact=t.interact;break;default:console.warn(`unknown resource`,t.resource)}}function Ft(e){let t=new Map;for(let n of Object.values(e.sessions)){if(!n)continue;let e=n.targetHost||n.sessionHost;if(!e)continue;let r=t.get(e)??[];r.push(n),t.set(e,r)}return t}function It(e){let t=new Map;for(let n of e.creds){if(!n||!n.host)continue;let e=t.get(n.host)??[];e.push(n),t.set(n.host,e)}return t}var[Q,Lt]=x(Mt()),[Rt,zt]=x(`connecting`),[Bt,Vt]=x(!1),[Ht,Ut]=x(``),[Wt,Gt]=x(!1);b(()=>{q.on(`hello`,e=>{Vt(!!e.team),Ut(typeof e.version==`string`?e.version:``)}),q.on(`snapshot`,e=>Lt(Nt(e.state))),q.on(`resource`,e=>Lt(t=>{let n={...t};return Pt(n,e),n})),q.on(`open`,()=>zt(`open`)),q.on(`closed`,()=>zt(`closed`)),q.on(`proto_mismatch`,()=>Gt(!0))});var Kt=b(()=>T(()=>Ft(Q()))),qt=b(()=>T(()=>It(Q()))),Jt=V(`<nav class=tree><div class=noresult>`),Yt=V(`<nav class=tree><div class=filterbox><i class="ph ph-magnifying-glass"></i><input placeholder="Filter modules"autocomplete=off aria-label="Filter modules"></div><ul></ul><div class=noresult>No module matches that filter.`),Xt=V(`<span class=cnt>`),Zt=V(`<li><button class="trow branch"><i></i><i></i><span class=tlabel>/</span></button><ul>`),Qt=V(`<li><button class="trow leaf"><i class="ph ph-file-code mfil"></i><span class=tlabel>`),$t=V(`<span>`);function en(e){let[t,n]=x(``),[r,i]=x(new Set([`exploit`])),a=T(()=>{let e=Q().modules;return e?Dt(e):null}),o=T(()=>a()?At(a(),t()):new Set),s=T(()=>{let e=Q().modules;return e?[e.exploits,e.auxiliary,e.post,e.payloads,e.encoders,e.nops,e.evasion].reduce((e,t)=>e+(t?.length??0),0):0});function c(e){i(t=>{let n=new Set(t);return n.has(e)?n.delete(e):n.add(e),n})}function l(t,n,r){let i=[{head:n.name,sub:n.path}];n.children.length===0&&n.path!==r&&i.push({icon:`rocket-launch`,label:`Launch…`,fn:()=>e.onLaunch(r,n.path)}),i.push({sep:!0},{icon:`copy`,label:`Copy path`,fn:()=>navigator.clipboard.writeText(n.path)}),mt(t,i)}return L(z,{get when(){return a()},get fallback(){return(()=>{var e=Jt(),t=e.firstChild;return G(t,()=>s()===0?`Not connected.`:`No modules.`),e})()},children:i=>(()=>{var a=Yt(),s=a.firstChild,u=s.firstChild.nextSibling,d=s.nextSibling;return u.$$input=e=>n(e.currentTarget.value),U(u,`spellcheck`,!1),G(d,L(R,{get each(){return i().children},children:n=>L(tn,{node:n,get type(){return n.type},open:r,toggle:c,get keep(){return o()},get query(){return t()},onMenu:l,get onLaunch(){return e.onLaunch}})})),C(()=>a.classList.toggle(`filtered`,!!(t()&&o().size===0))),C(()=>u.value=t()),a})()})}function tn(e){let t=()=>e.query?e.keep.has(e.node.path):e.open().has(e.node.path),n=()=>!e.query||e.keep.has(e.node.path);return L(z,{get when(){return n()},get children(){var n=Zt(),r=n.firstChild,i=r.firstChild,a=i.nextSibling,o=a.nextSibling,s=o.firstChild,c=r.nextSibling;return r.$$contextmenu=t=>e.onMenu(t,e.node,e.type),r.$$click=()=>e.toggle(e.node.path),G(o,()=>e.node.name,s),G(r,L(z,{get when(){return e.node.count>0},get children(){var t=Xt();return G(t,()=>e.node.count.toLocaleString()),t}}),null),G(c,L(R,{get each(){return kt(e.node.children,t())},children:t=>t.children.length===0?(()=>{var n=Qt(),r=n.firstChild,i=r.firstChild.nextSibling;return r.$$contextmenu=n=>e.onMenu(n,t,e.type),r.$$dblclick=()=>e.onLaunch(e.type,t.path),G(i,()=>t.name),G(r,L(z,{get when(){return jt(Q().moduleRanks?.[t.path])},children:e=>(()=>{var t=$t();return G(t,()=>e().label),C(()=>W(t,`rankchip${e().hot?` hot`:``}`)),t})()}),null),C(()=>U(r,`title`,t.path)),n})():L(tn,{node:t,get type(){return e.type},get open(){return e.open},get toggle(){return e.toggle},get keep(){return e.keep},get query(){return e.query},get onMenu(){return e.onMenu},get onLaunch(){return e.onLaunch}})})),C(e=>{var n=!!t(),o=t(),s=`ph ${t()?`ph-caret-down`:`ph-caret-right`} caret`,l=`ph ph-folder${t()?`-open`:``} fic`,u=!!t();return n!==e.e&&r.classList.toggle(`expanded`,e.e=n),o!==e.t&&U(r,`aria-expanded`,e.t=o),s!==e.a&&W(i,e.a=s),l!==e.o&&W(a,e.o=l),u!==e.i&&c.classList.toggle(`open`,e.i=u),e},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0}),n}})}H([`input`,`click`,`contextmenu`,`dblclick`]);function nn(e,t){let n=t.trim();if(!n||e.at(-1)===n)return e;let r=[...e,n];return r.length>500?r.slice(r.length-500):r}function rn(e,t,n){let r=n===`up`?Math.min(t+1,e.length-1):Math.max(t-1,-1);return{idx:r,text:r===-1?``:e[e.length-1-r]??``}}function an(e,t,n){if(t.length===0)return null;let r=e.split(/\s+/);if(t.length===1)return t[0]===r[r.length-1]?null:(r[r.length-1]=t[0],r.join(` `));let i=t[0];for(let e of t.slice(1))for(;!e.startsWith(i);)i=i.slice(0,-1);return i.length<=n.length?null:(r[r.length-1]=i,r.join(` `))}var on=V(`<input placeholder="type a framework command"autocomplete=off>`),sn=V(`<div class=console><div class=inputline><span class=pr>`),cn=V(`<div class="cl out">`),ln=V(`<span>framework is busy`);function un(e){let[t,n]=x(``),[r,i]=x([]),[a,o]=x(-1),s,c,l=()=>e.output().split(` -`);w(()=>{e.output(),s&&(s.scrollTop=s.scrollHeight)});function u(){if(e.busy)return;let r=t();if(!r.trim()){n(``);return}e.write(r),i(e=>nn(e,r)),o(-1),n(``)}async function d(i){if(i.key===`Enter`){i.preventDefault(),u();return}if(i.key===`Tab`){i.preventDefault();let r=t(),a=r.split(/\s+/).at(-1)??``;if(!a)return;try{let i=await e.tabComplete(r);if(t()!==r)return;let o=an(r,i,a);o!==null&&n(o)}catch{}return}if(i.key===`ArrowUp`||i.key===`ArrowDown`){i.preventDefault();let e=r();if(!e.length)return;let t=rn(e,a(),i.key===`ArrowUp`?`up`:`down`);o(t.idx),n(t.text)}}return(()=>{var r=sn(),i=r.firstChild,a=i.firstChild;r.$$click=()=>c?.focus();var o=s;return typeof o==`function`?ke(o,r):s=r,G(r,L(R,{get each(){return l()},children:e=>(()=>{var t=cn();return G(t,e||`\xA0`),t})()}),i),G(a,()=>e.prompt),G(i,L(z,{get when(){return!e.busy},get fallback(){return ln()},get children(){var e=on();e.$$keydown=e=>void d(e),e.$$input=e=>n(e.currentTarget.value);var r=c;return typeof r==`function`?ke(r,e):c=e,U(e,`spellcheck`,!1),C(()=>e.value=t()),e}}),null),r})()}H([`click`,`input`,`keydown`]);var dn=`modulepreload`,fn=function(e){return`/`+e},pn={},mn=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=fn(t,n),t=s(t),t in pn)return;pn[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:dn,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},hn=`multi/manage/autoroute`;function gn(e){if(!Number.isInteger(e)||e<0||e>32)return;let t=e=>e>=8?255:e<=0?0:256-2**(8-e);return[0,8,16,24].map(n=>t(e-n)).join(`.`)}function _n(e){let t=e.lastIndexOf(`/`);if(t<0)return{subnet:e};let n=gn(Number(e.slice(t+1)));return n===void 0?{subnet:e}:{subnet:e.slice(0,t),netmask:n}}function vn(e){let t=e.trim();if(t===``)return;let n=Number(t);return gn(n)===void 0?void 0:n}function yn(e){let t=e.trim().split(`.`);return t.length===4&&t.every(e=>/^\d{1,3}$/.test(e)&&Number(e)<=255)}function bn(e){return{SESSION:e,CMD:`autoadd`}}function xn(e,t,n){return{SESSION:e,CMD:`add`,SUBNET:t,NETMASK:gn(n)??`255.255.255.0`}}function Sn(e,t){let n={SESSION:e,CMD:`delete`,SUBNET:t.subnet};return t.netmask&&(n.NETMASK=t.netmask),n}function Cn(e,t){let n=e.filter(e=>e.subnet&&e.sessionId);if(n.length===0)return[];let r=[{head:n[0].subnet,sub:`pivot route`}];for(let e of n)r.push({icon:`signpost`,label:`Remove route via session ${e.sessionId}`,danger:!0,fn:()=>t(e)});return r}async function wn(e){let{ws:t}=await mn(async()=>{let{ws:e}=await Promise.resolve().then(()=>Ge);return{ws:e}},void 0);await t.command(`module.execute`,{type:`post`,name:hn,options:e})}function Tn(e){let t=(e.osName||``).toLowerCase();return t.includes(`windows`)?{key:`win`,label:(e.osName||`windows`).replace(`Microsoft Windows`,`WIN`).replace(`Windows Server`,`SRV`).replace(`Windows`,`WIN`).toUpperCase()}:[`linux`,`ubuntu`,`debian`,`centos`,`red hat`,`redhat`,`fedora`,`suse`].some(e=>t.includes(e))?{key:`linux`,label:(e.osName||`linux`).toUpperCase().slice(0,14)}:{key:`other`,label:(e.osName||`unknown`).toUpperCase().slice(0,14)}}var En;function $(e){let t=document.getElementById(`statusflash`);t&&(t.textContent=e,t.classList.add(`show`),window.clearTimeout(En),En=window.setTimeout(()=>t.classList.remove(`show`),2600))}var Dn=36,On=60,kn=3;function An(e,t){return e.length<=t?e:e.slice(0,Math.max(1,t-1))+`…`}var jn=/^\d{1,3}$/;function Mn(e){let t=e.split(`.`);return Nn(e)===void 0?`other`:`${t[0]}.${t[1]}.${t[2]}`}function Nn(e){let t=e.split(`.`);if(t.length!==4)return;let n=0;for(let e of t){if(!jn.test(e))return;let t=Number(e);if(t>255)return;n=n*256+t}return n}function Pn(e,t){let n=e.lastIndexOf(`/`),r=n<0?`32`:e.slice(n+1);if(r===``)return!1;let i=Number(r);if(!Number.isInteger(i)||i<0||i>32)return!1;let a=Nn(n<0?e:e.slice(0,n)),o=Nn(t);if(a===void 0||o===void 0)return!1;let s=i===0?0:4294967295<<32-i>>>0;return(a&s)>>>0==(o&s)>>>0}function Fn(e,t,n){return`${e.filter(e=>!!e).map(e=>e.address).join(`|`)}#${t.filter(e=>!!e).map(e=>{let t=n[e.sessionId],r=t&&(t.targetHost||t.sessionHost)||``;return`${e.subnet}>${e.sessionId}@${r}`}).join(`|`)}`}function In(e){let t=new Map;for(let n of e){let e=Mn(n.address),r=t.get(e)??[];r.push(n.address),t.set(e,r)}return new Map([...t].sort(([e],[t])=>e===`other`?1:t===`other`?-1:e.localeCompare(t,void 0,{numeric:!0})).map(([e,t])=>[e,t.sort((e,t)=>Number(e.split(`.`)[3]??0)-Number(t.split(`.`)[3]??0))]))}function Ln(e){let t=new Map,n=0;for(let r of In(e).values()){r.forEach((e,r)=>{let i=r%kn,a=Math.floor(r/kn);t.set(e,{x:18+i*242,y:n+58+a*114})});let e=Math.ceil(r.length/kn);n+=58+e*78+Math.max(0,e-1)*Dn+18+On}return t}function Rn(e,t){let n=Ln(e);for(let[e,r]of t)n.has(e)&&n.set(e,r);return n}function zn(e,t=Ln(e)){let n=new Map;for(let[r,i]of In(e)){let e=i.map(e=>t.get(e)).filter(e=>!!e);if(e.length===0)continue;let a=Math.min(...e.map(e=>e.x)),o=Math.min(...e.map(e=>e.y)),s=Math.max(...e.map(e=>e.x+216)),c=Math.max(...e.map(e=>e.y+78));n.set(r,{x:a-18,y:o-58,w:s-a+36,h:c-o+58+18,count:i.length})}return n}var Bn=`hayduk.topology.positions.v3`;function Vn(e=localStorage){try{let t=e.getItem(Bn);if(!t)return new Map;let n=JSON.parse(t);if(!n||typeof n!=`object`||Array.isArray(n))return new Map;let r=new Map;for(let[e,t]of Object.entries(n)){let n=t;typeof n?.x==`number`&&Number.isFinite(n.x)&&typeof n.y==`number`&&Number.isFinite(n.y)&&r.set(e,{x:n.x,y:n.y})}return r}catch{return new Map}}function Hn(e,t=localStorage){try{t.setItem(Bn,JSON.stringify(Object.fromEntries(e)))}catch{}}function Un(e,t){let n=new Map;for(let[r,i]of e)t.has(r)&&n.set(r,i);return n}var Wn=V(`<svg><g><g class=topo-zones></g><g class=topo-routes></g><g class=topo-hosts></g><g class=topo-ghosts></svg>`,!1,!0,!1),Gn=V(`<svg id=topo role=group aria-label="Campaign network topology"><defs><linearGradient id=topo-node-fill x1=0 y1=0 x2=0 y2=1><stop offset=0 stop-color=#1e2634></stop><stop offset=1 stop-color=#141a23></stop></linearGradient><filter id=topo-node-shadow x=-30% y=-35% width=160% height=180%><feDropShadow dx=0 dy=6 stdDeviation=7 flood-color=#000000 flood-opacity=.32></feDropShadow></filter><filter id=topo-node-glow x=-40% y=-40% width=180% height=180%><feDropShadow dx=0 dy=0 stdDeviation=5 flood-color=#e2666b flood-opacity=.4></feDropShadow></filter><marker id=topo-route-arrow markerWidth=8 markerHeight=8 refX=7 refY=4 orient=auto markerUnits=strokeWidth><path d="M 0 0 L 8 4 L 0 8 z">`),Kn=V(`<svg><g class=topo-empty><text x=50% y=48%>No hosts discovered</text><text class=topo-empty-sub x=50% y=54%>Scan a network to build the topology.</svg>`,!1,!0,!1),qn=V(`<svg><g class=topo-zone-routed><rect width=62 height=20 rx=5></rect><circle cx=11 cy=10 r=2.5></circle><text x=19 y=13>ROUTED</svg>`,!1,!0,!1),Jn=V(`<svg><circle class=topo-route-port r=4></svg>`,!1,!0,!1),Yn=V(`<svg><g class=topo-zone><g><circle class=topo-zone-icon-bg cx=14 cy=14 r=14></circle><circle class=topo-zone-icon cx=9 cy=14 r=2></circle><circle class=topo-zone-icon cx=19 cy=9 r=2></circle><circle class=topo-zone-icon cx=19 cy=19 r=2></circle><path class=topo-zone-link d="M 11 13 L 17 10 M 11 15 L 17 18"></path></g><text class=topo-zone-name></text><text class=topo-zone-meta> </text><path class=topo-zone-spine></svg>`,!1,!0,!1),Xn=V(`<svg><path class=topo-zone-branch></svg>`,!1,!0,!1),Zn=V(`<svg><circle class=topo-zone-junction r=2.5></svg>`,!1,!0,!1),Qn=V(`<svg><g><path class=topo-route-halo></path><path class=topo-route-line marker-end=url(#topo-route-arrow)></path><g class=topo-route-label><rect height=20 rx=5></rect><text x=0 y=13>SESSION </svg>`,!1,!0,!1),$n=V(`<svg><g class="topo-state access"><rect width=60 height=18 rx=5></rect><circle class=topo-live-halo cx=11 cy=9 r=3></circle><circle class=topo-live-dot cx=11 cy=9 r=2.6></circle><text x=20 y=12>ACCESS</svg>`,!1,!0,!1),er=V(`<svg><g class="topo-state login"><rect width=54 height=18 rx=5></rect><circle cx=11 cy=9 r=2.5></circle><text x=20 y=12>LOGIN</svg>`,!1,!0,!1),tr=V(`<svg><g class=topo-node tabIndex=0 role=button><title>`,!1,!0,!1),nr=V(`ROUTED NETWORKAWAITING DISCOVERY`,!1,!0,!1),rr=210,ir=76,ar=18;function or(e){return Math.max(64,40+e.length*7)}function sr(e){if(e.lane!==void 0)return`M ${e.from.x} ${e.from.y} C ${e.lane} ${e.from.y}, ${e.lane} ${e.to.y}, ${e.to.x} ${e.to.y}`;let t=Math.max(44,Math.abs(e.to.x-e.from.x)*.42);return`M ${e.from.x} ${e.from.y} C ${e.from.x+t} ${e.from.y}, ${e.to.x-t} ${e.to.y}, ${e.to.x} ${e.to.y}`}function cr(e,t,n){return Math.min(Math.max(e,t),n)}function lr(e){let t,n,r=0,[i,a]=x({x:0,y:0,s:1}),[o,s]=x(Vn()),c=T(()=>Q().hosts.filter(e=>!!e)),l=T(()=>Rn(c(),o())),u=T(()=>zn(c(),l())),d=T(()=>{let e=Kt(),t=qt();return n=>({sessions:e.get(n)??[],login:(t.get(n)?.length??0)>0})}),f=T(()=>{let e=Q(),t=[];for(let n of e.routes){if(!n?.subnet||!n.sessionId)continue;let e=new Set;for(let t of c())Pn(n.subnet,t.address)&&e.add(Mn(t.address));t.push({route:{subnet:n.subnet,sessionId:n.sessionId},covered:e})}return t}),p=T(()=>{let e=new Set;for(let{covered:t}of f())for(let n of t)e.add(n);return e}),m=T(()=>{let e=new Map,t=u(),n=l(),r=Q(),i=Math.max(0,...[...t.values()].map(e=>e.x+e.w))+84;for(let{route:t,covered:a}of f()){if(a.size>0||e.has(t.subnet))continue;let o=r.sessions[t.sessionId],s=o?.targetHost||o?.sessionHost,c=s?n.get(s):void 0,l=c?c.y+39-ir/2:e.size*94,u=[...e.values()].at(-1);u&&(l=Math.max(l,u.y+ir+ar)),e.set(t.subnet,{label:t.subnet,x:i,y:l})}return e}),h=T(()=>{let e=Q(),t=l(),n=u(),r=m(),i=[];for(let{route:a,covered:o}of f()){let s=e.sessions[a.sessionId],c=s?.targetHost||s?.sessionHost,l=c?t.get(c):void 0;if(!l)continue;let u={x:l.x+216,y:l.y+39},d=!1;for(let e of o){let t=n.get(e);if(!t)continue;let r=Math.max(u.x,t.x+t.w)+64+i.length*16,o={x:t.x+t.w,y:t.y+14};i.push({sessionID:a.sessionId,subnet:a.subnet,from:u,to:o,lane:r,label:{x:r-40,y:(u.y+o.y)/2-11},labelW:or(a.sessionId)}),d=!0}if(d)continue;let f=r.get(a.subnet);if(f){let e={x:f.x,y:f.y+ir/2};i.push({sessionID:a.sessionId,subnet:a.subnet,from:u,to:e,label:{x:(u.x+e.x)/2,y:(u.y+e.y)/2-13},labelW:or(a.sessionId)})}}return i}),g=T(()=>{let e=[...[...u().values()].map(e=>({x:e.x,y:e.y,w:e.w,h:e.h})),...[...m().values()].map(e=>({x:e.x,y:e.y,w:rr,h:ir}))];if(e.length===0)return;let t=Math.min(...e.map(e=>e.x)),n=Math.min(...e.map(e=>e.y)),r=Math.max(...e.map(e=>e.x+e.w)),i=Math.max(...e.map(e=>e.y+e.h));for(let e of h())e.lane!==void 0&&(r=Math.max(r,e.lane+10)),t=Math.min(t,e.from.x,e.to.x),n=Math.min(n,e.from.y,e.to.y),r=Math.max(r,e.from.x,e.to.x),i=Math.max(i,e.from.y,e.to.y);return{x:t,y:n,w:r-t,h:i-n}});function _(){let e=g();if(!e||!t)return;let n=t.getBoundingClientRect();if(n.width===0||n.height===0)return;let r={left:38,right:38,top:54,bottom:58},i=Math.max(.02,Math.min((n.width-r.left-r.right)/e.w,(n.height-r.top-r.bottom)/e.h,1.16));a({s:i,x:r.left+(n.width-r.left-r.right-e.w*i)/2-e.x*i,y:r.top+(n.height-r.top-r.bottom-e.h*i)/2-e.y*i})}function v(){cancelAnimationFrame(r),r=requestAnimationFrame(_)}function y(e,n,r){if(!t)return;let o=t.getBoundingClientRect(),s=n??o.width/2,c=r??o.height/2,l=i(),u=cr(l.s*e,.05,4);a({s:u,x:s-(s-l.x)*(u/l.s),y:c-(c-l.y)*(u/l.s)})}function b(e){e.preventDefault();let n=t.getBoundingClientRect();y(e.deltaY<0?1.12:1/1.12,e.clientX-n.left,e.clientY-n.top)}function S(e,n){let r=t.getBoundingClientRect(),a=i();return{x:(e-r.left-a.x)/a.s,y:(n-r.top-a.y)/a.s}}function ee(e){wn(Sn(e.sessionId,_n(e.subnet))).catch(t=>$(t?.message??`could not remove route ${e.subnet}`))}function E(e,t){mt(t,Cn([{subnet:e.subnet,sessionId:e.sessionID}],ee))}function D(e,t){mt(t,Cn(Q().routes.filter(t=>!!t&&t.subnet===e).map(e=>({subnet:e.subnet,sessionId:e.sessionId})),ee))}function ne(t,n){e.onSelect(t);let r=d()(t),i=[{head:t,sub:`host`}];for(let t of r.sessions)i.push({icon:`terminal-window`,label:`Interact with session ${t.id}`,fn:()=>e.onInteract(t.id)});i.push({icon:`rocket-launch`,label:`Run exploit…`,fn:()=>e.onLaunch(t)},{icon:`key`,label:`Login as…`,fn:()=>e.onLogin(t)},{sep:!0},{icon:`copy`,label:`Copy address`,hint:t,fn:()=>navigator.clipboard.writeText(t)}),mt(n,i)}let A,j;function re(e){if(e.button!==0)return;let n=e.target.closest(`.topo-node`);if(n?.dataset.address){let t=S(e.clientX,e.clientY),r=l().get(n.dataset.address);if(!r)return;j={address:n.dataset.address,dx:t.x-r.x,dy:t.y-r.y,startX:e.clientX,startY:e.clientY,moved:!1,snapshot:new Map(o())}}else{let n=i();A={startX:e.clientX,startY:e.clientY,viewX:n.x,viewY:n.y,moved:!1},t.classList.add(`panning`)}t.setPointerCapture(e.pointerId)}function ie(e){if(j){if(Math.abs(e.clientX-j.startX)+Math.abs(e.clientY-j.startY)>4&&(j.moved=!0),!j.moved)return;let t=S(e.clientX,e.clientY),n={x:t.x-j.dx,y:t.y-j.dy};s(e=>new Map(e).set(j.address,n));return}A&&(Math.abs(e.clientX-A.startX)+Math.abs(e.clientY-A.startY)>4&&(A.moved=!0),a(t=>({...t,x:A.viewX+e.clientX-A.startX,y:A.viewY+e.clientY-A.startY})))}function ae(){if(j&&!j.moved&&e.onSelect(j.address),j?.moved){let e=new Set(c().map(e=>e.address)),t=Un(o(),e);s(t),Hn(t)}A&&!A.moved&&e.onSelect(void 0),j=void 0,A=void 0,t.classList.remove(`panning`)}function oe(){j&&s(j.snapshot),j=void 0,A=void 0,t.classList.remove(`panning`)}return O(()=>{let e=()=>_(),i=e=>y(e.detail?.k??1);window.addEventListener(`hayduk:fit`,e),window.addEventListener(`hayduk:zoom`,i),n=new ResizeObserver(v),n.observe(t),v(),k(()=>{cancelAnimationFrame(r),n?.disconnect(),window.removeEventListener(`hayduk:fit`,e),window.removeEventListener(`hayduk:zoom`,i)})}),w(te(T(()=>{let e=Q();return Fn(e.hosts,e.routes,e.sessions)}),()=>v(),{defer:!0})),(()=>{var n=Gn();n.firstChild,n.addEventListener(`pointercancel`,oe),n.$$pointerup=ae,n.$$pointermove=ie,n.$$pointerdown=re,n.addEventListener(`wheel`,b);var r=t;return typeof r==`function`?ke(r,n):t=n,G(n,L(z,{get when(){return g()},get fallback(){return Kn()},get children(){var t=Wn(),n=t.firstChild,r=n.nextSibling,a=r.nextSibling,o=a.nextSibling;return G(n,L(R,{get each(){return[...u()]},children:([e,t])=>(()=>{var n=Yn(),r=n.firstChild,i=r.nextSibling,a=i.nextSibling,o=a.firstChild,s=a.nextSibling;return G(i,e===`other`?`OTHER HOSTS`:`${e}.0/24`),G(a,()=>t.count,o),G(a,()=>t.count===1?`HOST`:`HOSTS`,null),G(n,L(R,{get each(){return c().filter(t=>Mn(t.address)===e)},children:e=>{let n=()=>l().get(e.address);return L(z,{get when(){return n()},children:e=>[(()=>{var n=Xn();return C(()=>U(n,`d`,`M ${t.x+14} ${e().y-15} H ${e().x+108} V ${e().y}`)),n})(),(()=>{var t=Zn();return C(n=>{var r=e().x+108,i=e().y-15;return r!==n.e&&U(t,`cx`,n.e=r),i!==n.t&&U(t,`cy`,n.t=i),n},{e:void 0,t:void 0}),t})()]})}}),null),G(n,L(z,{get when(){return p().has(e)},get children(){return[(()=>{var e=qn();return C(()=>U(e,`transform`,`translate(${t.x+t.w-68} ${t.y+4})`)),e})(),(()=>{var e=Jn();return C(n=>{var r=t.x+t.w,i=t.y+14;return r!==n.e&&U(e,`cx`,n.e=r),i!==n.t&&U(e,`cy`,n.t=i),n},{e:void 0,t:void 0}),e})()]}}),null),C(e=>{var n=`translate(${t.x} ${t.y})`,o=t.x+40,c=t.y+13,l=t.x+40,u=t.y+28,d=`M ${t.x+14} ${t.y+30} V ${t.y+t.h-18-78-15}`;return n!==e.e&&U(r,`transform`,e.e=n),o!==e.t&&U(i,`x`,e.t=o),c!==e.a&&U(i,`y`,e.a=c),l!==e.o&&U(a,`x`,e.o=l),u!==e.i&&U(a,`y`,e.i=u),d!==e.n&&U(s,`d`,e.n=d),e},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0,n:void 0}),n})()})),G(r,L(R,{get each(){return h()},children:e=>(()=>{var t=Qn(),n=t.firstChild,r=n.nextSibling,i=r.nextSibling,a=i.firstChild,o=a.nextSibling;return o.firstChild,t.$$contextmenu=t=>E(e,t),G(o,()=>e.sessionID,null),C(t=>{var o=sr(e),s=sr(e),c=`translate(${e.label.x} ${e.label.y})`,l=-e.labelW/2,u=e.labelW;return o!==t.e&&U(n,`d`,t.e=o),s!==t.t&&U(r,`d`,t.t=s),c!==t.a&&U(i,`transform`,t.a=c),l!==t.o&&U(a,`x`,t.o=l),u!==t.i&&U(a,`width`,t.i=u),t},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0}),t})()})),G(a,L(R,{get each(){return c()},children:t=>{let n=()=>l().get(t.address),r=()=>d()(t.address),i=()=>Tn(t);return L(z,{get when(){return n()},children:n=>(()=>{var a=tr(),o=a.firstChild,s=o.nextSibling,c=s.nextSibling,l=c.nextSibling,u=l.nextSibling,d=u.nextSibling.nextSibling;return a.$$keydown=n=>{(n.key===`Enter`||n.key===` `)&&(n.preventDefault(),e.onSelect(t.address))},a.$$contextmenu=e=>ne(t.address,e),G(o,()=>`${t.name||t.address} · ${t.address}`),U(s,`width`,216),U(s,`height`,78),G(l,()=>An(i().label,14)),G(u,()=>An(t.name||t.address,20)),G(d,()=>An(t.address,16)),G(a,L(z,{get when(){return r().sessions.length>0},get children(){var e=$n();return U(e,`transform`,`translate(141 52)`),e}}),null),G(a,L(z,{get when(){return B(()=>!!r().login)()&&r().sessions.length===0},get children(){var e=er();return U(e,`transform`,`translate(147 52)`),e}}),null),C(o=>{var s=e.selected()===t.address,u=r().sessions.length>0,d=!!(r().login&&r().sessions.length===0),f=t.address,p=`translate(${n().x} ${n().y})`,m=`${t.name||t.address}, ${t.address}`,h=`topo-device ${i().key}`,g=`topo-node-os ${i().key}`;return s!==o.e&&a.classList.toggle(`selected`,o.e=s),u!==o.t&&a.classList.toggle(`access`,o.t=u),d!==o.a&&a.classList.toggle(`login`,o.a=d),f!==o.o&&U(a,`data-address`,o.o=f),p!==o.i&&U(a,`transform`,o.i=p),m!==o.n&&U(a,`aria-label`,o.n=m),h!==o.s&&U(c,`class`,o.s=h),g!==o.h&&U(l,`class`,o.h=g),o},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0,n:void 0,s:void 0,h:void 0}),a})()})}})),G(o,L(R,{get each(){return Array.from(m().entries())},children:([e,t])=>(()=>{var n=nr(),r=n.firstChild.nextSibling.nextSibling.nextSibling;return n.$$contextmenu=t=>D(e,t),G(r,()=>An(t.label,19)),C(()=>U(n,`transform`,`translate(${t.x} ${t.y})`)),n})()})),C(()=>U(t,`transform`,`translate(${i().x} ${i().y}) scale(${i().s})`)),t}}),null),n})()}H([`pointerdown`,`pointermove`,`pointerup`,`contextmenu`,`keydown`]);var ur=V(`
`),dr=V(``),mr=V(`
`),fr=V(`
`),pr=V(`
`);function hr(e){return(()=>{var t=ur(),n=t.firstChild.firstChild,r=n.firstChild,i=n.nextSibling;return G(r,L(R,{get each(){return e.columns},children:e=>(()=>{var t=dr();return G(t,()=>e.label),C(n=>De(t,e.width?`width:${e.width}`:``,n)),t})()})),G(i,L(z,{get when(){return e.rows.length>0},get fallback(){return L(z,{get when(){return e.empty},get children(){var t=fr(),n=t.firstChild;return G(n,()=>e.empty),C(()=>U(n,`colspan`,e.columns.length)),t}})},get children(){return L(R,{get each(){return e.rows},children:t=>(()=>{var n=pr();return n.$$contextmenu=n=>e.onRowContextMenu?.(t,n),n.$$click=()=>e.onRowClick?.(t),G(n,L(R,{get each(){return e.columns},children:e=>(()=>{var n=mr();return G(n,(()=>{var n=B(()=>!!e.render);return()=>n()?e.render(t):String(t[e.key]??``)})()),C(()=>W(n,e.mono?`m`:``)),n})()})),C(()=>n.classList.toggle(`sel`,e.selectedKey?.()===e.rowKey(t))),n})()})}})),t})()}H([`click`,`contextmenu`]);function gr(e=3e4){let[t,n]=x(Date.now());return O(()=>{let t=window.setInterval(()=>n(Date.now()),e);k(()=>window.clearInterval(t))}),t}var _r=V(`

Route traffic to another network through this session. The new pivot appears on the topology as a dashed route edge.`),vr=V(`

hosts creds live sessions
access obtainedlive sessionlogin possiblepivot route
HAYDUK
Hayduk needs a desktop window.
This is a dense operator console. Open it in a viewport
wider than 960px, ideally 1280px or more.`),No=V(``),Po=V(`