Skip to content

Commit 2351240

Browse files
committed
feat: add manual session topology management
1 parent a7adf89 commit 2351240

15 files changed

Lines changed: 842 additions & 14 deletions

client/command/sessions/commands.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ remove 08d6c05a21512a79a1dfeb9d2a8f262f
136136
common.BindArgCompletions(removeCommand, nil, common.SessionIDCompleter(con))
137137
plugin.SetCommandArgs(removeCommand, plugin.SessionArg("session", true, 0))
138138

139-
sessCmd.AddCommand(bindSessNewCmd, noteCommand, groupCommand, removeCommand)
139+
linkCommand := newSessionLinkCommand(con)
140+
sessCmd.AddCommand(bindSessNewCmd, noteCommand, groupCommand, removeCommand, linkCommand)
140141
useCommand := &cobra.Command{
141142
Use: consts.CommandUse + " [session]",
142143
Short: "Use a session",

client/command/sessions/link.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package sessions
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/carapace-sh/carapace"
8+
"github.com/chainreactors/IoM-go/proto/client/clientpb"
9+
"github.com/chainreactors/malice-network/client/command/common"
10+
"github.com/chainreactors/malice-network/client/core"
11+
"github.com/chainreactors/tui"
12+
"github.com/evertras/bubble-table/table"
13+
"github.com/spf13/cobra"
14+
"github.com/spf13/pflag"
15+
)
16+
17+
func newSessionLinkCommand(con *core.Console) *cobra.Command {
18+
linkCommand := &cobra.Command{
19+
Use: "link",
20+
Short: "Manage session parent-child relationships",
21+
Long: "List and manage manually assigned parent-child relationships between sessions.",
22+
Example: `~~~
23+
session link
24+
session link --parent 08d6c05a21512a79a1dfeb9d2a8f262f
25+
~~~`,
26+
Args: cobra.NoArgs,
27+
RunE: func(cmd *cobra.Command, _ []string) error {
28+
return listSessionLinksCmd(cmd, con)
29+
},
30+
}
31+
bindSessionLinkFilterFlags(linkCommand, con)
32+
33+
listCommand := &cobra.Command{
34+
Use: "list",
35+
Short: "List session parent-child relationships",
36+
Long: "List all session links, or filter them by parent and child session IDs.",
37+
Example: `~~~
38+
session link list
39+
session link list --child 08d6c05a21512a79a1dfeb9d2a8f262f
40+
~~~`,
41+
Args: cobra.NoArgs,
42+
RunE: func(cmd *cobra.Command, _ []string) error {
43+
return listSessionLinksCmd(cmd, con)
44+
},
45+
}
46+
bindSessionLinkFilterFlags(listCommand, con)
47+
48+
setCommand := &cobra.Command{
49+
Use: "set",
50+
Aliases: []string{"reparent"},
51+
Short: "Set or replace a session parent",
52+
Long: "Assign a parent to a session. If the child already has a parent, replace the existing relationship.",
53+
Example: `~~~
54+
session link set --parent 08d6c05a21512a79a1dfeb9d2a8f262f --child b2bc23d0325a476ea01308d93f15f9da
55+
session link reparent --parent c459870f1f854653a81407d7018ee756 --child b2bc23d0325a476ea01308d93f15f9da
56+
~~~`,
57+
Args: cobra.NoArgs,
58+
RunE: func(cmd *cobra.Command, _ []string) error {
59+
return setSessionLinkCmd(cmd, con)
60+
},
61+
}
62+
common.BindFlag(setCommand, func(flags *pflag.FlagSet) {
63+
flags.String("parent", "", "parent session id")
64+
flags.String("child", "", "child session id")
65+
})
66+
_ = setCommand.MarkFlagRequired("parent")
67+
_ = setCommand.MarkFlagRequired("child")
68+
bindSessionLinkFlagCompletions(setCommand, con)
69+
70+
unlinkCommand := &cobra.Command{
71+
Use: "unlink",
72+
Short: "Remove a session parent relationship",
73+
Long: "Detach a child session from its current parent while preserving the child's own descendants.",
74+
Example: `~~~
75+
session link unlink --child b2bc23d0325a476ea01308d93f15f9da
76+
~~~`,
77+
Args: cobra.NoArgs,
78+
RunE: func(cmd *cobra.Command, _ []string) error {
79+
return removeSessionLinkCmd(cmd, con)
80+
},
81+
}
82+
common.BindFlag(unlinkCommand, func(flags *pflag.FlagSet) {
83+
flags.String("child", "", "child session id")
84+
})
85+
_ = unlinkCommand.MarkFlagRequired("child")
86+
common.BindFlagCompletions(unlinkCommand, func(comp carapace.ActionMap) {
87+
comp["child"] = common.AllSessionIDCompleter(con)
88+
})
89+
90+
linkCommand.AddCommand(listCommand, setCommand, unlinkCommand)
91+
return linkCommand
92+
}
93+
94+
func bindSessionLinkFilterFlags(cmd *cobra.Command, con *core.Console) {
95+
common.BindFlag(cmd, func(flags *pflag.FlagSet) {
96+
flags.String("parent", "", "filter by parent session id")
97+
flags.String("child", "", "filter by child session id")
98+
})
99+
bindSessionLinkFlagCompletions(cmd, con)
100+
}
101+
102+
func bindSessionLinkFlagCompletions(cmd *cobra.Command, con *core.Console) {
103+
common.BindFlagCompletions(cmd, func(comp carapace.ActionMap) {
104+
comp["parent"] = common.AllSessionIDCompleter(con)
105+
comp["child"] = common.AllSessionIDCompleter(con)
106+
})
107+
}
108+
109+
func listSessionLinksCmd(cmd *cobra.Command, con *core.Console) error {
110+
parentSessionID, err := resolveSessionLinkFlag(con, cmd, "parent")
111+
if err != nil {
112+
return err
113+
}
114+
childSessionID, err := resolveSessionLinkFlag(con, cmd, "child")
115+
if err != nil {
116+
return err
117+
}
118+
119+
links, err := con.Rpc.ListSessionLinks(con.Context(), &clientpb.SessionLinkRequest{
120+
ParentSessionId: parentSessionID,
121+
ChildSessionId: childSessionID,
122+
})
123+
if err != nil {
124+
return err
125+
}
126+
if len(links.GetLinks()) == 0 {
127+
con.Log.Console("No session links found\n")
128+
return nil
129+
}
130+
printSessionLinks(con, links.GetLinks())
131+
return nil
132+
}
133+
134+
func setSessionLinkCmd(cmd *cobra.Command, con *core.Console) error {
135+
parentSessionID, err := resolveSessionLinkFlag(con, cmd, "parent")
136+
if err != nil {
137+
return err
138+
}
139+
childSessionID, err := resolveSessionLinkFlag(con, cmd, "child")
140+
if err != nil {
141+
return err
142+
}
143+
144+
link, err := con.Rpc.SetSessionLink(con.Context(), &clientpb.SessionLinkRequest{
145+
ParentSessionId: parentSessionID,
146+
ChildSessionId: childSessionID,
147+
})
148+
if err != nil {
149+
return err
150+
}
151+
con.Log.Console(fmt.Sprintf("Session %s now uses %s as its parent\n", link.GetChildSessionId(), link.GetParentSessionId()))
152+
return nil
153+
}
154+
155+
func removeSessionLinkCmd(cmd *cobra.Command, con *core.Console) error {
156+
childSessionID, err := resolveSessionLinkFlag(con, cmd, "child")
157+
if err != nil {
158+
return err
159+
}
160+
if _, err := con.Rpc.RemoveSessionLink(con.Context(), &clientpb.SessionLinkRequest{ChildSessionId: childSessionID}); err != nil {
161+
return err
162+
}
163+
con.Log.Console(fmt.Sprintf("Session %s detached from its parent\n", childSessionID))
164+
return nil
165+
}
166+
167+
func resolveSessionLinkFlag(con *core.Console, cmd *cobra.Command, name string) (string, error) {
168+
sessionID, err := cmd.Flags().GetString(name)
169+
if err != nil || sessionID == "" {
170+
return sessionID, err
171+
}
172+
return resolveSessionID(con, sessionID)
173+
}
174+
175+
func printSessionLinks(con *core.Console, links []*clientpb.SessionLink) {
176+
rows := make([]table.Row, 0, len(links))
177+
for _, link := range links {
178+
if link == nil {
179+
continue
180+
}
181+
updatedAt := "-"
182+
if link.GetUpdatedAt() > 0 {
183+
updatedAt = time.Unix(link.GetUpdatedAt(), 0).Format("2006-01-02 15:04:05")
184+
}
185+
rows = append(rows, table.NewRow(table.RowData{
186+
"Parent": link.GetParentSessionId(),
187+
"Child": link.GetChildSessionId(),
188+
"Source": link.GetSource(),
189+
"Updated": updatedAt,
190+
}))
191+
}
192+
193+
tableModel := tui.NewTable([]table.Column{
194+
table.NewFlexColumn("Parent", "Parent", 1),
195+
table.NewFlexColumn("Child", "Child", 1),
196+
table.NewColumn("Source", "Source", 10),
197+
table.NewColumn("Updated", "Updated", 19),
198+
}, true)
199+
tableModel.SetMultiline()
200+
tableModel.SetRows(rows)
201+
tableModel.Title = "session links"
202+
con.Log.Console(tableModel.View())
203+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package sessions
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
iomclient "github.com/chainreactors/IoM-go/client"
8+
"github.com/chainreactors/IoM-go/proto/client/clientpb"
9+
"github.com/chainreactors/IoM-go/proto/services/clientrpc"
10+
"google.golang.org/grpc"
11+
)
12+
13+
type sessionLinkCommandRPC struct {
14+
clientrpc.MaliceRPCClient
15+
16+
listRequest *clientpb.SessionLinkRequest
17+
setRequest *clientpb.SessionLinkRequest
18+
removeRequest *clientpb.SessionLinkRequest
19+
}
20+
21+
func (rpc *sessionLinkCommandRPC) ListSessionLinks(_ context.Context, req *clientpb.SessionLinkRequest, _ ...grpc.CallOption) (*clientpb.SessionLinks, error) {
22+
rpc.listRequest = req
23+
return &clientpb.SessionLinks{}, nil
24+
}
25+
26+
func (rpc *sessionLinkCommandRPC) SetSessionLink(_ context.Context, req *clientpb.SessionLinkRequest, _ ...grpc.CallOption) (*clientpb.SessionLink, error) {
27+
rpc.setRequest = req
28+
return &clientpb.SessionLink{
29+
ParentSessionId: req.GetParentSessionId(),
30+
ChildSessionId: req.GetChildSessionId(),
31+
Source: "manual",
32+
}, nil
33+
}
34+
35+
func (rpc *sessionLinkCommandRPC) RemoveSessionLink(_ context.Context, req *clientpb.SessionLinkRequest, _ ...grpc.CallOption) (*clientpb.Empty, error) {
36+
rpc.removeRequest = req
37+
return &clientpb.Empty{}, nil
38+
}
39+
40+
func TestSessionLinkCommandSetReparentAndUnlink(t *testing.T) {
41+
con := newSessionTestConsole(t)
42+
parent := addSessionFixture(t, con, "link-command-parent")
43+
child := addSessionFixture(t, con, "link-command-child")
44+
rpc := &sessionLinkCommandRPC{}
45+
con.Server.ServerState.Rpc = &iomclient.Rpc{MaliceRPCClient: rpc}
46+
con.Server.ServerState.Client = &clientpb.Client{Name: "tester", ID: 1}
47+
48+
command := Commands(con)[0]
49+
command.SetArgs([]string{"link", "reparent", "--parent", parent.SessionId, "--child", child.SessionId})
50+
if err := command.Execute(); err != nil {
51+
t.Fatalf("session link reparent failed: %v", err)
52+
}
53+
if rpc.setRequest == nil || rpc.setRequest.GetParentSessionId() != parent.SessionId || rpc.setRequest.GetChildSessionId() != child.SessionId {
54+
t.Fatalf("set request = %#v", rpc.setRequest)
55+
}
56+
57+
command = Commands(con)[0]
58+
command.SetArgs([]string{"link", "unlink", "--child", child.SessionId})
59+
if err := command.Execute(); err != nil {
60+
t.Fatalf("session link unlink failed: %v", err)
61+
}
62+
if rpc.removeRequest == nil || rpc.removeRequest.GetChildSessionId() != child.SessionId {
63+
t.Fatalf("remove request = %#v", rpc.removeRequest)
64+
}
65+
}
66+
67+
func TestSessionLinkCommandListFilters(t *testing.T) {
68+
con := newSessionTestConsole(t)
69+
parent := addSessionFixture(t, con, "link-list-parent")
70+
child := addSessionFixture(t, con, "link-list-child")
71+
rpc := &sessionLinkCommandRPC{}
72+
con.Server.ServerState.Rpc = &iomclient.Rpc{MaliceRPCClient: rpc}
73+
con.Server.ServerState.Client = &clientpb.Client{Name: "tester", ID: 1}
74+
75+
command := Commands(con)[0]
76+
command.SetArgs([]string{"link", "list", "--parent", parent.SessionId, "--child", child.SessionId})
77+
if err := command.Execute(); err != nil {
78+
t.Fatalf("session link list failed: %v", err)
79+
}
80+
if rpc.listRequest == nil || rpc.listRequest.GetParentSessionId() != parent.SessionId || rpc.listRequest.GetChildSessionId() != child.SessionId {
81+
t.Fatalf("list request = %#v", rpc.listRequest)
82+
}
83+
}

docs/architecture.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ flowchart TB
7070
%% 数据层
7171
subgraph data["数据层"]
7272
direction LR
73-
db["SQLite / PostgreSQL<br/>Operator / Session / Task"]
73+
db["SQLite / PostgreSQL<br/>Operator / Session / SessionLink / Task"]
7474
context["Runtime Files<br/>Context / Audit / Logs / Web"]
7575
proto["IoM-go Proto<br/>Client / Listener / Implant contracts"]
7676
assets["Profiles / Artifacts / Certificates"]
@@ -116,7 +116,7 @@ flowchart TB
116116
| 展现层 | `client/cmd/cli/`, `client/command/`, `client/plugin/`, `external/IoM-go` | 提供 CLI/TUI、SDK、LocalRPC、MCP、MAL/Armory 和 Agent 入口。 |
117117
| 通讯层 | `server/rpc/`, `server/forwardrpc/`, `server/listener/`, `external/rem` | 承载 Client 到 Server、Server 到 Listener、Listener 到 Implant 的通信协议和连接形态。 |
118118
| 服务层 | `server/rpc/`, `server/internal/core/`, `server/build/`, `server/internal/mutant/`, `server/internal/llm/`, `server/internal/notify/` | 维护认证授权、Session/Task/Event 调度、Listener/Pipeline 管理、构建产物、插件自动化、审计和通知能力。 |
119-
| 数据层 | `server/internal/db/`, `server/internal/configs/`, `server/internal/audit/`, `server/assets/`, `external/IoM-go/generate/proto/` | 持久化 Operator、Session、Task、Pipeline、Artifact、Context、Website 内容,保存配置、证书、审计日志和 proto 契约。 |
119+
| 数据层 | `server/internal/db/`, `server/internal/configs/`, `server/internal/audit/`, `server/assets/`, `external/IoM-go/generate/proto/` | 持久化 Operator、Session、SessionLink、Task、Pipeline、Artifact、Context、Website 内容,保存配置、证书、审计日志和 proto 契约。 |
120120

121121
## 运行时链路
122122

@@ -135,10 +135,17 @@ flowchart TB
135135
5. JobStream 断开后 Listener 重新注册并建流;新快照提交后,以 `SyncPipeline` 恢复 Server 对仍在运行的 Pipeline/Website 的视图。
136136
6. 上述过程恢复控制面状态,不代表 Implant 的 TCP/HTTP 连接已经恢复;实际在线仍以新的 Register/Checkin 和连接建立为准。
137137

138+
### Session 逻辑拓扑
139+
140+
1. Session 之间的父子关系由独立的 `SessionLink` 模型持久化,不属于 Listener、Pipeline 或 REM 的运行时连接状态。
141+
2. 当前拓扑由 Client 通过 `ListSessionLinks``SetSessionLink``RemoveSessionLink` 人工维护;一个子 Session 只能有一个父 Session,服务端拒绝自关联和环路。
142+
3. 逻辑拓扑当前不参与 Task 路由。后续接入 REM 或 Implant 级联信道时,由信道生命周期更新关系来源和状态,而不是从拓扑表反向假定连接已经存在。
143+
138144
## 相关文档
139145

140146
- [核心概念](concept.md)
141147
- [Server 内部机制](server/internals.md)
142148
- [Listener 与 Pipeline 架构](server/listeners.md)
143149
- [构建与 Profile](server/build.md)
144150
- [Server 冷启动 Session 恢复](server/session-startup-recovery-design.md)
151+
- [Session 父子关系](client/session-links.md)

docs/client/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- [命令行系统](console.md) - 上下文架构、TUI 多窗口、MCP/LocalRPC 集成
1111
- [任务查询](tasks.md) - 任务列表、请求摘要、raw request 与结果导出
1212
- [审计导出](audit.md) - Session 级 Task 审计 JSON/HTML 导出
13+
- [Session 父子关系](session-links.md) - 人工设置、重新挂载、解除和查询 Session 拓扑
1314
- [管理命令](management-commands.md) - cert、pipeline、listener、job、artifact、website 管理入口
1415
- [Pipeline 证书](pipeline-certificates.md) - HTTP/TCP Pipeline 证书绑定、换绑、续期与引用重载
1516
- [Project 管理](project.md) - Project 创建、查询、更新和删除
@@ -39,7 +40,7 @@ Client 的命令按职责分组,不同上下文下可用命令不同:
3940
| 命令组 | 命令 | 操作文档 |
4041
|--------|------|----------|
4142
| Generic | `login` / `version` / `status` / `exit` / `!` | [快速开始](quickstart.md) |
42-
| Manage | `session` / `project` / `mal` / `alias` / `extension` / `armory` / `config` / `cert` / `audit` / `context` | [后渗透操作](../operations/post-exploitation/)[Project 管理](project.md)[审计导出](audit.md)[管理命令](management-commands.md)[插件体系](plugin.md) |
43+
| Manage | `session` / `project` / `mal` / `alias` / `extension` / `armory` / `config` / `cert` / `audit` / `context` | [后渗透操作](../operations/post-exploitation/)[Session 父子关系](session-links.md)[Project 管理](project.md)[审计导出](audit.md)[管理命令](management-commands.md)[插件体系](plugin.md) |
4344
| Listener | `listener` / `pipeline` / `website` / `job` | [Listener 操作](../operations/listener.md)[管理命令](management-commands.md) |
4445
| Generator | `build` / `profile` / `mutant` / `artifact` | [构建操作](../operations/build.md)[管理命令](management-commands.md) |
4546

0 commit comments

Comments
 (0)