From 5e0cb12a6b60cb00cfc045d83f280d25ee6c5de9 Mon Sep 17 00:00:00 2001 From: jolovicdev <184168873+jolovicdev@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:30:28 +0200 Subject: [PATCH 1/5] Engine: scope workspaces, paginate reads, attribute sessions --- internal/engine/commands.go | 57 +++++-- internal/engine/commands_test.go | 104 +++++++++++- internal/engine/connect.go | 82 +++++++++- internal/engine/console.go | 22 ++- internal/engine/disconnect_test.go | 31 ++++ internal/engine/engine.go | 76 ++++++++- internal/engine/engine_test.go | 245 ++++++++++++++++++++++++++++ internal/engine/hailmary.go | 12 +- internal/engine/ingest.go | 80 +++++++-- internal/engine/integration_test.go | 232 ++++++++++++++++++++++++++ internal/engine/ranks.go | 24 ++- internal/engine/ranks_test.go | 49 +++++- internal/engine/refresh.go | 68 ++++++-- internal/engine/refresh_test.go | 43 +++++ internal/engine/report.go | 76 ++++++--- internal/engine/report_test.go | 72 ++++++++ internal/engine/upgrade_test.go | 6 +- internal/protocol/protocol.go | 11 +- ui/src/protocol/types.ts | 14 ++ 19 files changed, 1210 insertions(+), 94 deletions(-) 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/ui/src/protocol/types.ts b/ui/src/protocol/types.ts index f320255..2393b05 100644 --- a/ui/src/protocol/types.ts +++ b/ui/src/protocol/types.ts @@ -218,6 +218,11 @@ export interface SessionState { targetHost?: string; sessionHost?: string; uuid?: string; + /** + * Workspace is the msf workspace active when the session opened; empty + * for sessions picked up without one. Reports scope sessions on it. + */ + workspace?: string; openedAt?: string; } export interface JobState { @@ -273,6 +278,11 @@ export interface EventEntry { level: string; text: string; operator?: string; + /** + * Workspace is the msf workspace active when the event was recorded; + * empty for framework-wide events. Reports filter events on it. + */ + workspace?: string; } export interface CampaignState { connection: ConnectionState; @@ -422,6 +432,10 @@ export const MethodWorkspaceList = "workspace.list"; * Command methods. */ export const MethodWorkspaceSet = "workspace.set"; +/** + * Command methods. + */ +export const MethodOperatorJoin = "operator.join"; /** * Command methods. */ From dc2fa9dabdf212394412efd79129bdcef2a3e65c Mon Sep 17 00:00:00 2001 From: jolovicdev <184168873+jolovicdev@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:30:28 +0200 Subject: [PATCH 2/5] Server: per-instance cookies and operator presence --- internal/server/dist/assets/index-Bd1vBLnh.js | 4 - internal/server/dist/assets/index-ouGMlgL6.js | 4 + internal/server/dist/index.html | 2 +- internal/server/server.go | 35 ++++-- internal/server/server_test.go | 118 +++++++++++++++--- 5 files changed, 137 insertions(+), 26 deletions(-) delete mode 100644 internal/server/dist/assets/index-Bd1vBLnh.js create mode 100644 internal/server/dist/assets/index-ouGMlgL6.js 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(`