feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物
主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
777d9e4e71
commit
76ec9387f0
202 files changed
+16527
-2515
No files matched your search
@@ -8,6 +8,7 @@ frontend/node_modules/
|
|||||||
*.dmg
|
*.dmg
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
|
.cache/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/dist-ssr/
|
frontend/dist-ssr/
|
||||||
backend/bin/
|
backend/bin/
|
||||||
@@ -80,6 +81,7 @@ docker-data/
|
|||||||
*.pid
|
*.pid
|
||||||
agent/aiops-agent
|
agent/aiops-agent
|
||||||
agent/bin/
|
agent/bin/
|
||||||
|
backend/agent/
|
||||||
|
|
||||||
# embed 模式临时目录(前端构建产物,编译后清理)
|
# embed 模式临时目录(前端构建产物,编译后清理)
|
||||||
# 保留 .gitkeep 占位文件确保 go:embed all:static 在全新 clone 后可编译
|
# 保留 .gitkeep 占位文件确保 go:embed all:static 在全新 clone 后可编译
|
||||||
|
|||||||
@@ -220,8 +220,8 @@ aiops/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
CGO_ENABLED=1 go build -o aiops-server .
|
CGO_ENABLED=1 go build -o aiops .
|
||||||
./aiops-server
|
./aiops
|
||||||
# 或
|
# 或
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
@@ -320,7 +320,7 @@ npm run lint # ESLint 检查
|
|||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go build -o aiops-server .
|
go build -o aiops .
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -361,7 +361,7 @@ go build -o aiops-server .
|
|||||||
3. **新增 handler 不直接绑 model**,使用独立 request struct。
|
3. **新增 handler 不直接绑 model**,使用独立 request struct。
|
||||||
4. **GORM 指针字段必须解引用**后再使用(`*field`)。
|
4. **GORM 指针字段必须解引用**后再使用(`*field`)。
|
||||||
5. **Update handler 禁止复用 CreateRequest**,使用 `map[string]interface{}` 接收。
|
5. **Update handler 禁止复用 CreateRequest**,使用 `map[string]interface{}` 接收。
|
||||||
6. **编译参数**:`unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops-server .`
|
6. **编译参数**:`unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops .`
|
||||||
7. **GORM `First()` 连续查询会带上次 ID**,请用 `Take()` 代替。
|
7. **GORM `First()` 连续查询会带上次 ID**,请用 `Take()` 代替。
|
||||||
8. 文件名使用 `snake_case.go`;类型/方法 `PascalCase`;变量 `camelCase`。
|
8. 文件名使用 `snake_case.go`;类型/方法 `PascalCase`;变量 `camelCase`。
|
||||||
|
|
||||||
@@ -510,7 +510,7 @@ WebSocket 无法设置自定义 header → 后端 `JWTAuth` 支持 `?token=` que
|
|||||||
|
|
||||||
修改代码后,按以下顺序验证:
|
修改代码后,按以下顺序验证:
|
||||||
|
|
||||||
1. `cd backend && go build -o aiops-server .`(零错误)
|
1. `cd backend && go build -o aiops .`(零错误)
|
||||||
2. `cd frontend && npx tsc --noEmit`(零错误)
|
2. `cd frontend && npx tsc --noEmit`(零错误)
|
||||||
3. `./run.sh docker up` 或 `./run.sh dev` 启动完整环境
|
3. `./run.sh docker up` 或 `./run.sh dev` 启动完整环境
|
||||||
4. `python3 tests/integration_test.py --host localhost --port 8080` 通过
|
4. `python3 tests/integration_test.py --host localhost --port 8080` 通过
|
||||||
|
|||||||
@@ -117,10 +117,10 @@
|
|||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
# 编译
|
# 编译
|
||||||
unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops-server ./cmd/
|
unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops .
|
||||||
|
|
||||||
# 直接运行
|
# 直接运行
|
||||||
go run ./cmd/
|
go run .
|
||||||
|
|
||||||
# 代码检查
|
# 代码检查
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|||||||
+2
-2
@@ -32,7 +32,7 @@ COPY frontend/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ==================== Stage 2: Backend Builder ====================
|
# ==================== Stage 2: Backend Builder ====================
|
||||||
FROM golang:1.24-alpine AS backend-builder
|
FROM golang:1.25-alpine AS backend-builder
|
||||||
|
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
ARG BUILD_TIME
|
ARG BUILD_TIME
|
||||||
@@ -52,7 +52,7 @@ ENV GONOSUMCHECK=*
|
|||||||
ENV GOMODCACHE=/go/pkg/mod
|
ENV GOMODCACHE=/go/pkg/mod
|
||||||
ENV GOCACHE=/root/.cache/go-build
|
ENV GOCACHE=/root/.cache/go-build
|
||||||
|
|
||||||
# 复制后端 go.mod(利用 Docker 层缓存)
|
# 复制后端 go.mod,第三方 RDP 兜底依赖已移除,后端使用自研 RDP 引擎。 // Logan.Li
|
||||||
COPY backend/go.mod backend/go.sum ./
|
COPY backend/go.mod backend/go.sum ./
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
go mod download
|
go mod download
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
@@ -45,7 +45,7 @@ build-ui:
|
|||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
@echo "构建后端..."
|
@echo "构建后端..."
|
||||||
cd backend && CGO_ENABLED=0 go build -o aiops-server ./cmd/
|
cd backend && CGO_ENABLED=1 go build -o aiops .
|
||||||
|
|
||||||
build: build-api build-ui
|
build: build-api build-ui
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ lint:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
@echo "清理构建产物..."
|
@echo "清理构建产物..."
|
||||||
rm -f backend/aiops-server
|
rm -f backend/aiops backend/aiops-server
|
||||||
rm -rf frontend/dist
|
rm -rf frontend/dist
|
||||||
rm -f backend/logs/*.log frontend/logs/*.log
|
rm -f backend/logs/*.log frontend/logs/*.log
|
||||||
@echo "清理完成"
|
@echo "清理完成"
|
||||||
@@ -90,9 +90,9 @@ docker-logs:
|
|||||||
# ==================== 二进制部署(embed 模式) ====================
|
# ==================== 二进制部署(embed 模式) ====================
|
||||||
build-binary: build-ui
|
build-binary: build-ui
|
||||||
@echo "构建单二进制(前端嵌入后端)..."
|
@echo "构建单二进制(前端嵌入后端)..."
|
||||||
@mkdir -p backend/cmd/static
|
@rm -rf backend/static
|
||||||
@cp -r frontend/dist/* backend/cmd/static/
|
@mkdir -p backend/static
|
||||||
cd backend && CGO_ENABLED=0 go build -ldflags="-s -w" -o ../aiops ./cmd/
|
@cp -r frontend/dist/* backend/static/
|
||||||
@rm -rf backend/cmd/static
|
cd backend && CGO_ENABLED=1 go build -ldflags="-s -w" -o ../aiops .
|
||||||
@echo "✓ 二进制已生成: ./aiops"
|
@echo "✓ 二进制已生成: ./aiops"
|
||||||
@echo " 部署: scp aiops deploy/binary/install.sh root@<server>:/tmp/ && ssh root@<server> 'cd /tmp && bash install.sh install'"
|
@echo " 部署: scp aiops deploy/binary/install.sh root@<server>:/tmp/ && ssh root@<server> 'cd /tmp && bash install.sh install'"
|
||||||
@@ -236,4 +236,4 @@ aiops/
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
GNU General Public License v3.0 or later (GPL-3.0-or-later). See [LICENSE](./LICENSE).
|
||||||
+1
-1
@@ -301,4 +301,4 @@ docker run -p 8080:8080 -v $(pwd)/config.yaml:/root/config.yaml ezops-backend
|
|||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
MIT License
|
GNU General Public License v3.0 or later (GPL-3.0-or-later). See the root `LICENSE` file.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,227 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# AIOps Agent 一键安装脚本 — 支持 Linux(systemd) 和 macOS(launchd)
|
|
||||||
# 用法: curl -fsSL http://YOUR_SERVER/agent/install.sh | sudo bash -s -- --server http://YOUR_SERVER:8080 --token TOKEN
|
|
||||||
# 署名 Logan.Li
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# 默认参数
|
|
||||||
AIOPS_SERVER=""
|
|
||||||
AGENT_TOKEN=""
|
|
||||||
INSTALL_DIR="/usr/local/bin"
|
|
||||||
SERVICE_NAME="aiops-agent"
|
|
||||||
PLIST_LABEL="com.ezops.aiops-agent"
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
KERNEL=$(uname -s) # Linux 或 Darwin
|
|
||||||
|
|
||||||
# 检测 OS 显示名
|
|
||||||
detect_os() {
|
|
||||||
if [ "$KERNEL" = "Darwin" ]; then
|
|
||||||
echo "$(sw_vers -productName 2>/dev/null || echo 'macOS') $(sw_vers -productVersion 2>/dev/null || echo '')"
|
|
||||||
elif [ -f /etc/os-release ]; then
|
|
||||||
. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-${NAME:-Linux}}" || echo "Linux"
|
|
||||||
else
|
|
||||||
echo "Linux"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
OS_DISPLAY=$(detect_os)
|
|
||||||
|
|
||||||
# OS 小写 (linux / darwin)
|
|
||||||
OS_LOWER=$(echo "$KERNEL" | tr '[:upper:]' '[:lower:]')
|
|
||||||
|
|
||||||
# 解析命令行参数
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--server) AIOPS_SERVER="$2"; shift 2;;
|
|
||||||
--token) AGENT_TOKEN="$2"; shift 2;;
|
|
||||||
--version) AGENT_VERSION="$2"; shift 2;;
|
|
||||||
*) echo "未知参数: $1"; exit 1;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
AGENT_VERSION="${AGENT_VERSION:-v1.0.0}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " ╔══════════════════════════════════════════╗"
|
|
||||||
echo " ║ AIOps Agent 一键安装 ║"
|
|
||||||
echo " ╚══════════════════════════════════════════╝"
|
|
||||||
echo ""
|
|
||||||
echo " 系统: ${OS_DISPLAY} (${ARCH})"
|
|
||||||
echo " 内核: ${KERNEL}"
|
|
||||||
echo " 版本: ${AGENT_VERSION}"
|
|
||||||
echo " 后端: ${AIOPS_SERVER:-未指定}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 检查 root 权限
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
echo "❌ 请使用 root 或 sudo 执行此脚本"
|
|
||||||
echo " 推荐用法: curl -fsSL <url> | sudo bash -s -- --server <url> --token <token>"
|
|
||||||
echo " (sudo 放在 bash 前,不是 curl 前)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 确定下载架构
|
|
||||||
case "$ARCH" in
|
|
||||||
x86_64|amd64) DOWNLOAD_ARCH="amd64";;
|
|
||||||
aarch64|arm64) DOWNLOAD_ARCH="arm64";;
|
|
||||||
*)
|
|
||||||
echo "❌ 不支持的 CPU 架构: $ARCH (仅支持 x86_64/amd64 和 aarch64/arm64)"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
echo " 匹配: ${OS_LOWER}/${DOWNLOAD_ARCH}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 安装依赖工具
|
|
||||||
if command -v curl &> /dev/null; then
|
|
||||||
echo "✓ curl 已安装"
|
|
||||||
else
|
|
||||||
echo "→ 安装 curl..."
|
|
||||||
if command -v yum &> /dev/null; then
|
|
||||||
yum install -y curl
|
|
||||||
elif command -v dnf &> /dev/null; then
|
|
||||||
dnf install -y curl
|
|
||||||
elif command -v apt-get &> /dev/null; then
|
|
||||||
apt-get update -qq && apt-get install -y curl
|
|
||||||
elif command -v brew &> /dev/null; then
|
|
||||||
brew install curl
|
|
||||||
else
|
|
||||||
echo "❌ 无法确定包管理器,请手动安装 curl"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 下载二进制
|
|
||||||
if [ -z "$AIOPS_SERVER" ]; then
|
|
||||||
echo "❌ 请通过 --server 指定 AIOps 后端地址"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BINARY_URL="${AIOPS_SERVER}/agent/aiops-agent-${OS_LOWER}-${DOWNLOAD_ARCH}"
|
|
||||||
echo "→ 下载 Agent: ${BINARY_URL}"
|
|
||||||
|
|
||||||
curl -fSL -o /tmp/aiops-agent "${BINARY_URL}" || {
|
|
||||||
echo "❌ 下载失败: ${BINARY_URL}"
|
|
||||||
echo " 请检查: 1) 后端地址是否正确 2) 该平台是否有对应二进制"
|
|
||||||
if [ "$OS_LOWER" = "darwin" ]; then
|
|
||||||
echo " 提示: macOS 二进制需要后端编译 darwin 版本 (make build-darwin-arm64)"
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
chmod +x /tmp/aiops-agent
|
|
||||||
mv /tmp/aiops-agent "${INSTALL_DIR}/aiops-agent"
|
|
||||||
|
|
||||||
echo "✓ 安装到 ${INSTALL_DIR}/aiops-agent"
|
|
||||||
|
|
||||||
if [ -z "$AGENT_TOKEN" ]; then
|
|
||||||
echo "⚠ 未提供 --token,使用空令牌"
|
|
||||||
AGENT_TOKEN="none"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 平台分支: Linux(systemd) / macOS(launchd) ───
|
|
||||||
|
|
||||||
if [ "$OS_LOWER" = "darwin" ]; then
|
|
||||||
# ===== macOS: launchd 服务 =====
|
|
||||||
PLIST_PATH="/Library/LaunchDaemons/${PLIST_LABEL}.plist"
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$PLIST_PATH")"
|
|
||||||
|
|
||||||
cat > "$PLIST_PATH" << EOF
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>${PLIST_LABEL}</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>${INSTALL_DIR}/aiops-agent</string>
|
|
||||||
<string>-server</string>
|
|
||||||
<string>${AIOPS_SERVER}</string>
|
|
||||||
<string>-interval</string>
|
|
||||||
<string>10</string>
|
|
||||||
<string>-token</string>
|
|
||||||
<string>${AGENT_TOKEN}</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>/var/log/aiops-agent.log</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>/var/log/aiops-agent.err.log</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "✓ 创建 launchd 服务 ($PLIST_PATH)"
|
|
||||||
|
|
||||||
# 停止旧实例(如果存在)
|
|
||||||
launchctl unload "$PLIST_PATH" 2>/dev/null || true
|
|
||||||
# 加载并启动
|
|
||||||
launchctl load -w "$PLIST_PATH"
|
|
||||||
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# 检查运行状态
|
|
||||||
if launchctl list | grep -q "$PLIST_LABEL"; then
|
|
||||||
echo ""
|
|
||||||
echo "✅ 安装成功!Agent 已启动并设置为开机自启"
|
|
||||||
echo ""
|
|
||||||
echo " 查看状态: launchctl list | grep aiops"
|
|
||||||
echo " 查看日志: tail -f /var/log/aiops-agent.log"
|
|
||||||
echo " 手动停止: launchctl unload $PLIST_PATH"
|
|
||||||
echo " 手动启动: launchctl load -w $PLIST_PATH"
|
|
||||||
echo " 卸载: launchctl unload $PLIST_PATH && rm -f ${INSTALL_DIR}/aiops-agent $PLIST_PATH"
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
echo "❌ Agent 启动失败,请检查日志:"
|
|
||||||
echo " cat /var/log/aiops-agent.err.log"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
else
|
|
||||||
# ===== Linux: systemd 服务 =====
|
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
|
||||||
|
|
||||||
cat > "$SERVICE_FILE" << EOF
|
|
||||||
[Unit]
|
|
||||||
Description=AIOps Monitoring Agent
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=${INSTALL_DIR}/aiops-agent -server ${AIOPS_SERVER} -interval 10 -token ${AGENT_TOKEN}
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
LimitNOFILE=65536
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "✓ 创建 systemd 服务"
|
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable ${SERVICE_NAME}
|
|
||||||
systemctl restart ${SERVICE_NAME}
|
|
||||||
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
if systemctl is-active --quiet ${SERVICE_NAME}; then
|
|
||||||
echo ""
|
|
||||||
echo "✅ 安装成功!Agent 已启动并设置为开机自启"
|
|
||||||
echo ""
|
|
||||||
echo " 查看状态: systemctl status ${SERVICE_NAME}"
|
|
||||||
echo " 查看日志: journalctl -u ${SERVICE_NAME} -f"
|
|
||||||
echo " 卸载: systemctl stop ${SERVICE_NAME} && rm -f ${INSTALL_DIR}/aiops-agent ${SERVICE_FILE} && systemctl daemon-reload"
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
echo "❌ Agent 启动失败,请检查日志:"
|
|
||||||
echo " journalctl -u ${SERVICE_NAME} -n 20"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
+43
-2
@@ -66,15 +66,56 @@ func (a *AliyunProvider) sign(params map[string]string) string {
|
|||||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// call 调用阿里云 RPC API
|
// call 调用阿里云 ECS RPC API
|
||||||
func (a *AliyunProvider) call(action string, extra map[string]string, endpoint string) ([]byte, error) {
|
func (a *AliyunProvider) call(action string, extra map[string]string, endpoint string) ([]byte, error) {
|
||||||
if endpoint == "" {
|
if endpoint == "" {
|
||||||
endpoint = "https://ecs.aliyuncs.com/"
|
endpoint = "https://ecs.aliyuncs.com/"
|
||||||
}
|
}
|
||||||
|
return a.callWithVersion(action, extra, endpoint, a.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aliyunBSSEndpoints BSS 国内站与国际站接入点(站点不匹配时需自动切换) // Logan.Li
|
||||||
|
var aliyunBSSEndpoints = []string{
|
||||||
|
"https://business.aliyuncs.com/", // 国内站 aliyun.com
|
||||||
|
"https://business.ap-southeast-1.aliyuncs.com/", // 国际站 alibabacloud.com
|
||||||
|
}
|
||||||
|
|
||||||
|
// callBSS 调用阿里云费用中心 BSS OpenAPI,自动适配国内/国际站 // Logan.Li
|
||||||
|
func (a *AliyunProvider) callBSS(action string, extra map[string]string) ([]byte, error) {
|
||||||
|
var lastErr error
|
||||||
|
for _, endpoint := range aliyunBSSEndpoints {
|
||||||
|
body, err := a.callWithVersion(action, extra, endpoint, "2017-12-14")
|
||||||
|
if err == nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
if isAliyunBSSSiteMismatch(err) {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, fmt.Errorf("阿里云账单 API 站点不匹配(请确认 AK 属于国内站 aliyun.com 或国际站 alibabacloud.com): %w", lastErr)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("阿里云账单 API 调用失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAliyunBSSSiteMismatch 判断是否为国内/国际站 endpoint 不匹配 // Logan.Li
|
||||||
|
func isAliyunBSSSiteMismatch(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "NotApplicable") ||
|
||||||
|
strings.Contains(msg, "caller site") ||
|
||||||
|
strings.Contains(msg, "regionId")
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWithVersion 通用阿里云 RPC 签名请求
|
||||||
|
func (a *AliyunProvider) callWithVersion(action string, extra map[string]string, endpoint, version string) ([]byte, error) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"Format": "JSON",
|
"Format": "JSON",
|
||||||
"Version": a.version,
|
"Version": version,
|
||||||
"AccessKeyId": a.accessKey,
|
"AccessKeyId": a.accessKey,
|
||||||
"SignatureMethod": "HMAC-SHA1",
|
"SignatureMethod": "HMAC-SHA1",
|
||||||
"Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
"Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchDailyBill 拉取指定日期的阿里云日账单(按产品+地域聚合) // Logan.Li
|
||||||
|
func (a *AliyunProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) {
|
||||||
|
if len(billingDate) != 10 {
|
||||||
|
return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
billingCycle := billingDate[:7]
|
||||||
|
|
||||||
|
type aggKey struct {
|
||||||
|
service string
|
||||||
|
region string
|
||||||
|
}
|
||||||
|
agg := map[aggKey]float64{}
|
||||||
|
currency := "CNY"
|
||||||
|
|
||||||
|
pageNum := 1
|
||||||
|
pageSize := 300
|
||||||
|
totalCount := -1
|
||||||
|
|
||||||
|
for {
|
||||||
|
body, err := a.callBSS("QueryInstanceBill", map[string]string{
|
||||||
|
"BillingCycle": billingCycle,
|
||||||
|
"BillingDate": billingDate,
|
||||||
|
"Granularity": "DAILY",
|
||||||
|
"PageNum": strconv.Itoa(pageNum),
|
||||||
|
"PageSize": strconv.Itoa(pageSize),
|
||||||
|
"IsHideZeroCharge": "true",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp aliyunInstanceBillResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析账单响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if !resp.Success && resp.Code != "Success" {
|
||||||
|
msg := resp.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = string(body)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("阿里云账单 API 错误: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalCount < 0 {
|
||||||
|
totalCount = resp.Data.TotalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
items := normalizeBillItems(resp.Data.Items.Item)
|
||||||
|
for _, it := range items {
|
||||||
|
amount := it.PretaxAmount
|
||||||
|
if amount <= 0 {
|
||||||
|
amount = it.PaymentAmount
|
||||||
|
}
|
||||||
|
if amount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
service := strings.TrimSpace(it.ProductName)
|
||||||
|
if service == "" {
|
||||||
|
service = strings.TrimSpace(it.ProductCode)
|
||||||
|
}
|
||||||
|
if service == "" {
|
||||||
|
service = "其他"
|
||||||
|
}
|
||||||
|
region := strings.TrimSpace(it.Region)
|
||||||
|
if region == "" {
|
||||||
|
region = "全局"
|
||||||
|
}
|
||||||
|
key := aggKey{service: service, region: region}
|
||||||
|
agg[key] += amount
|
||||||
|
if it.Currency != "" {
|
||||||
|
currency = it.Currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 || pageNum*pageSize >= totalCount {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pageNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]CloudBillingRecord, 0, len(agg))
|
||||||
|
for key, cost := range agg {
|
||||||
|
records = append(records, CloudBillingRecord{
|
||||||
|
BillingDate: billingDate,
|
||||||
|
Provider: "aliyun",
|
||||||
|
Cost: math.Round(cost*100) / 100,
|
||||||
|
Currency: currency,
|
||||||
|
Service: key.service,
|
||||||
|
Region: key.region,
|
||||||
|
Description: "云API自动同步(阿里云 QueryInstanceBill)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type aliyunInstanceBillResponse struct {
|
||||||
|
Code string `json:"Code"`
|
||||||
|
Message string `json:"Message"`
|
||||||
|
Success bool `json:"Success"`
|
||||||
|
Data struct {
|
||||||
|
TotalCount int `json:"TotalCount"`
|
||||||
|
Items struct {
|
||||||
|
Item json.RawMessage `json:"Item"`
|
||||||
|
} `json:"Items"`
|
||||||
|
} `json:"Data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aliyunBillItem struct {
|
||||||
|
ProductCode string `json:"ProductCode"`
|
||||||
|
ProductName string `json:"ProductName"`
|
||||||
|
Region string `json:"Region"`
|
||||||
|
PretaxAmount float64 `json:"PretaxAmount"`
|
||||||
|
PaymentAmount float64 `json:"PaymentAmount"`
|
||||||
|
Currency string `json:"Currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeBillItems 兼容 Item 为对象或数组两种返回格式 // Logan.Li
|
||||||
|
func normalizeBillItems(raw json.RawMessage) []aliyunBillItem {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var items []aliyunBillItem
|
||||||
|
if err := json.Unmarshal(raw, &items); err == nil {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
var one aliyunBillItem
|
||||||
|
if err := json.Unmarshal(raw, &one); err == nil {
|
||||||
|
return []aliyunBillItem{one}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
// CloudBillingRecord 云账单同步记录(跨平台统一结构) // Logan.Li
|
||||||
|
type CloudBillingRecord struct {
|
||||||
|
BillingDate string `json:"billing_date"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Cost float64 `json:"cost"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BillingFetcher 云账单拉取接口
|
||||||
|
type BillingFetcher interface {
|
||||||
|
FetchDailyBill(billingDate string) ([]CloudBillingRecord, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBillingFetcher 根据云厂商创建账单拉取器
|
||||||
|
func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
||||||
|
switch provider {
|
||||||
|
case "aliyun":
|
||||||
|
if p := NewAliyunProvider(accessKey, secretKey); p != nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+128
-36
@@ -1,5 +1,5 @@
|
|||||||
// agent_cli.go - Agent CLI WebSocket 控制器 // Logan.Li
|
// agent_cli.go - Agent CLI WebSocket 控制器 // Logan.Li
|
||||||
// 驱动 pkg/agentcli 引擎:关联主机(model.Server 凭据)→ 构建引擎 → ReAct 循环,
|
// 驱动 pkg/agentcli 引擎:主机模式(SSH)或 K8s 模式(集群查询/Pod 执行)→ ReAct 循环,
|
||||||
// 通过 WebSocket 实时推送思考/工具调用/输出/结果,高危命令经前端确认,过程持久化到 DB。
|
// 通过 WebSocket 实时推送思考/工具调用/输出/结果,高危命令经前端确认,过程持久化到 DB。
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
@@ -78,22 +78,37 @@ type agentConn struct {
|
|||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
userID uint
|
userID uint
|
||||||
|
mode string
|
||||||
server model.Server
|
server model.Server
|
||||||
|
sshCfg remoteexec.SSHConfig
|
||||||
|
kubernetesCtx *kubernetesAgentContext
|
||||||
session *model.AgentCLISession
|
session *model.AgentCLISession
|
||||||
taskCtx context.Context
|
taskCtx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
totalTokens int
|
totalTokens int
|
||||||
tokenMu sync.Mutex
|
tokenMu sync.Mutex
|
||||||
writeMu sync.Mutex
|
writeMu sync.Mutex
|
||||||
confirms map[string]chan bool
|
confirms map[string]chan bool
|
||||||
cfMu sync.Mutex
|
cfMu sync.Mutex
|
||||||
convHistory []llm.Message // 多轮追问对话历史(不含 system) // Logan.Li
|
convHistory []llm.Message
|
||||||
running bool
|
running bool
|
||||||
runningMu sync.Mutex
|
runningMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run WebSocket 入口:GET /ws/agent-cli?id=<serverID>&token=<jwt> // Logan.Li
|
// Run WebSocket 入口 // Logan.Li
|
||||||
|
// 主机: GET /ws/agent-cli?mode=host&id=<serverID>&token=<jwt>
|
||||||
|
// Kubernetes: GET /ws/agent-cli?mode=kubernetes&cluster=<code>&namespace=<ns>&pod=<可选>&container=<可选>&token=<jwt>
|
||||||
func (h *AgentCLIHandler) Run(c *gin.Context) {
|
func (h *AgentCLIHandler) Run(c *gin.Context) {
|
||||||
|
mode := agentcli.NormalizeRunMode(c.DefaultQuery("mode", agentcli.RunModeHost))
|
||||||
|
if mode == agentcli.RunModeKubernetes {
|
||||||
|
h.runKubernetesWS(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.runHostWS(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHostWS 主机模式 WebSocket // Logan.Li
|
||||||
|
func (h *AgentCLIHandler) runHostWS(c *gin.Context) {
|
||||||
serverID := c.Query("id")
|
serverID := c.Query("id")
|
||||||
if serverID == "" {
|
if serverID == "" {
|
||||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少主机 id"})
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少主机 id"})
|
||||||
@@ -105,21 +120,17 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先升级 WebSocket:SSH 凭据 / LLM 配置预检放到升级之后,失败时通过 WS error 事件
|
|
||||||
// 把真实原因推给前端,避免握手 400 导致前端"一直转圈"看不到原因 // Logan.Li
|
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// SSH 凭据预检
|
|
||||||
sshCfg, err := buildSSHConfig(h.db, server)
|
sshCfg, err := buildSSHConfig(h.db, server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeWSCloseError(conn, "SSH 连接失败: "+err.Error())
|
writeWSCloseError(conn, "SSH 连接失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// LLM 配置预检
|
|
||||||
var llmCfg model.LLMConfig
|
var llmCfg model.LLMConfig
|
||||||
if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
|
if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
|
||||||
writeWSCloseError(conn, "LLM 服务未配置或未启用,请先在「大模型设置」中配置")
|
writeWSCloseError(conn, "LLM 服务未配置或未启用,请先在「大模型设置」中配置")
|
||||||
@@ -127,18 +138,57 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ac := &agentConn{
|
ac := &agentConn{
|
||||||
conn: conn, db: h.db,
|
conn: conn, db: h.db, mode: agentcli.RunModeHost,
|
||||||
userID: middleware.GetUserID(c), server: server,
|
userID: middleware.GetUserID(c), server: server, sshCfg: sshCfg,
|
||||||
taskCtx: context.Background(),
|
taskCtx: context.Background(), confirms: make(map[string]chan bool),
|
||||||
confirms: make(map[string]chan bool),
|
|
||||||
}
|
}
|
||||||
// 推送主机上下文
|
|
||||||
ac.write(wsEvent{Type: "session", SessionID: 0,
|
ac.write(wsEvent{Type: "session", SessionID: 0,
|
||||||
Message: fmt.Sprintf("已连接主机 %s(%s),LLM: %s。请描述要执行的任务。", server.Hostname, server.IP, llmCfg.Model)})
|
Message: fmt.Sprintf("已连接主机 %s(%s),LLM: %s。请描述要执行的任务。", server.Hostname, server.IP, llmCfg.Model)})
|
||||||
|
ac.wsReadLoop()
|
||||||
|
}
|
||||||
|
|
||||||
// 读循环:处理前端控制消息
|
// runKubernetesWS Kubernetes 模式 WebSocket // Logan.Li
|
||||||
|
func (h *AgentCLIHandler) runKubernetesWS(c *gin.Context) {
|
||||||
|
clusterCode, namespace, podName, container := parseKubernetesWSParams(c)
|
||||||
|
if clusterCode == "" || namespace == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少 cluster 或 namespace 参数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
kCtx, err := loadKubernetesAgentContext(h.db, clusterCode, namespace, podName)
|
||||||
|
if err != nil {
|
||||||
|
writeWSCloseError(conn, "Kubernetes 连接失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kCtx.container = container
|
||||||
|
|
||||||
|
var llmCfg model.LLMConfig
|
||||||
|
if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
|
||||||
|
writeWSCloseError(conn, "LLM 服务未配置或未启用,请先在「大模型设置」中配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ac := &agentConn{
|
||||||
|
conn: conn, db: h.db, mode: agentcli.RunModeKubernetes,
|
||||||
|
userID: middleware.GetUserID(c), kubernetesCtx: kCtx,
|
||||||
|
taskCtx: context.Background(), confirms: make(map[string]chan bool),
|
||||||
|
}
|
||||||
|
target := buildKubernetesTargetInfo(kCtx)
|
||||||
|
ac.write(wsEvent{Type: "session", SessionID: 0,
|
||||||
|
Message: fmt.Sprintf("已连接 Kubernetes 集群,LLM: %s。\n%s\n请描述要执行的任务。", llmCfg.Model, target)})
|
||||||
|
ac.wsReadLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsReadLoop 处理前端控制消息 // Logan.Li
|
||||||
|
func (ac *agentConn) wsReadLoop() {
|
||||||
for {
|
for {
|
||||||
_, data, err := conn.ReadMessage()
|
_, data, err := ac.conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ac.abort()
|
ac.abort()
|
||||||
return
|
return
|
||||||
@@ -153,13 +203,13 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
|
|||||||
ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"})
|
ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ac.startTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
|
ac.startTask(ctrl.Query, ctrl.SafetyMode)
|
||||||
case "continue":
|
case "continue":
|
||||||
if ctrl.Query == "" {
|
if ctrl.Query == "" {
|
||||||
ac.write(wsEvent{Type: "error", Message: "追问内容不能为空"})
|
ac.write(wsEvent{Type: "error", Message: "追问内容不能为空"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ac.continueTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
|
ac.continueTask(ctrl.Query, ctrl.SafetyMode)
|
||||||
case "resume":
|
case "resume":
|
||||||
if ctrl.SessionID == 0 {
|
if ctrl.SessionID == 0 {
|
||||||
ac.write(wsEvent{Type: "error", Message: "缺少 session_id"})
|
ac.write(wsEvent{Type: "error", Message: "缺少 session_id"})
|
||||||
@@ -189,7 +239,7 @@ func parseSafetyMode(mode string) safety.Mode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startTask 启动新任务(新会话,清空对话历史) // Logan.Li
|
// startTask 启动新任务(新会话,清空对话历史) // Logan.Li
|
||||||
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
|
func (ac *agentConn) startTask(query, safetyMode string) {
|
||||||
ac.abort()
|
ac.abort()
|
||||||
|
|
||||||
var llmCfg model.LLMConfig
|
var llmCfg model.LLMConfig
|
||||||
@@ -203,11 +253,20 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode st
|
|||||||
title = string(r[:60]) + "..."
|
title = string(r[:60]) + "..."
|
||||||
}
|
}
|
||||||
sess := &model.AgentCLISession{
|
sess := &model.AgentCLISession{
|
||||||
UserID: ac.userID, ServerID: ac.server.ID,
|
UserID: ac.userID, Mode: ac.mode, Title: title, Status: "running",
|
||||||
ServerName: ac.server.Hostname, ServerHost: ac.server.IP,
|
|
||||||
Title: title, Status: "running",
|
|
||||||
Provider: llmCfg.Provider, Model: llmCfg.Model,
|
Provider: llmCfg.Provider, Model: llmCfg.Model,
|
||||||
}
|
}
|
||||||
|
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
|
||||||
|
sess.ClusterID = ac.kubernetesCtx.cluster.ID
|
||||||
|
sess.ClusterName = ac.kubernetesCtx.cluster.Name
|
||||||
|
sess.Namespace = ac.kubernetesCtx.namespace
|
||||||
|
sess.PodName = ac.kubernetesCtx.podName
|
||||||
|
sess.Container = ac.kubernetesCtx.container
|
||||||
|
} else {
|
||||||
|
sess.ServerID = ac.server.ID
|
||||||
|
sess.ServerName = ac.server.Hostname
|
||||||
|
sess.ServerHost = ac.server.IP
|
||||||
|
}
|
||||||
if err := ac.db.Create(sess).Error; err != nil {
|
if err := ac.db.Create(sess).Error; err != nil {
|
||||||
ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()})
|
ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -215,7 +274,7 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode st
|
|||||||
ac.session = sess
|
ac.session = sess
|
||||||
ac.convHistory = nil
|
ac.convHistory = nil
|
||||||
ac.setTotalTokens(0)
|
ac.setTotalTokens(0)
|
||||||
ac.runTask(sshCfg, sess, query, safetyMode, llmCfg, false)
|
ac.runTask(sess, query, safetyMode, llmCfg, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resumeSession 从历史会话恢复上下文,供继续追问 // Logan.Li
|
// resumeSession 从历史会话恢复上下文,供继续追问 // Logan.Li
|
||||||
@@ -225,8 +284,16 @@ func (ac *agentConn) resumeSession(sessionID uint) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var sess model.AgentCLISession
|
var sess model.AgentCLISession
|
||||||
if err := ac.db.Where("user_id = ? AND server_id = ?", ac.userID, ac.server.ID).
|
q := ac.db.Where("user_id = ?", ac.userID)
|
||||||
First(&sess, sessionID).Error; err != nil {
|
if ac.mode == agentcli.RunModeKubernetes {
|
||||||
|
q = q.Where("mode IN ?", []string{agentcli.RunModeKubernetes, "k8s"})
|
||||||
|
if ac.kubernetesCtx != nil {
|
||||||
|
q = q.Where("cluster_id = ? AND namespace = ?", ac.kubernetesCtx.cluster.ID, ac.kubernetesCtx.namespace)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
q = q.Where("mode = ? AND server_id = ?", ac.mode, ac.server.ID)
|
||||||
|
}
|
||||||
|
if err := q.First(&sess, sessionID).Error; err != nil {
|
||||||
ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"})
|
ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -246,7 +313,7 @@ func (ac *agentConn) resumeSession(sessionID uint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// continueTask 在同一会话内追问(保留上下文) // Logan.Li
|
// continueTask 在同一会话内追问(保留上下文) // Logan.Li
|
||||||
func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
|
func (ac *agentConn) continueTask(query, safetyMode string) {
|
||||||
if ac.isRunning() {
|
if ac.isRunning() {
|
||||||
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
||||||
return
|
return
|
||||||
@@ -265,11 +332,11 @@ func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode
|
|||||||
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
|
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ac.runTask(sshCfg, ac.session, query, safetyMode, llmCfg, true)
|
ac.runTask(ac.session, query, safetyMode, llmCfg, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li
|
// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li
|
||||||
func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
|
func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
|
||||||
if ac.isRunning() {
|
if ac.isRunning() {
|
||||||
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
||||||
return
|
return
|
||||||
@@ -287,14 +354,28 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe
|
|||||||
|
|
||||||
coreCfg := core.DefaultConfig()
|
coreCfg := core.DefaultConfig()
|
||||||
coreCfg.RoundOffset = roundOffset
|
coreCfg.RoundOffset = roundOffset
|
||||||
|
coreCfg.Mode = ac.mode
|
||||||
|
|
||||||
engine, err := agentcli.NewEngine(agentcli.EngineConfig{
|
engCfg := agentcli.EngineConfig{
|
||||||
|
Mode: ac.mode,
|
||||||
LLM: buildLLMConfigFromModel(llmCfg),
|
LLM: buildLLMConfigFromModel(llmCfg),
|
||||||
SSH: sshCfg,
|
|
||||||
SafetyMode: parseSafetyMode(safetyMode),
|
SafetyMode: parseSafetyMode(safetyMode),
|
||||||
MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID),
|
MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID),
|
||||||
Core: coreCfg,
|
Core: coreCfg,
|
||||||
})
|
}
|
||||||
|
if ac.mode == agentcli.RunModeKubernetes {
|
||||||
|
if ac.kubernetesCtx == nil {
|
||||||
|
ac.write(wsEvent{Type: "error", Message: "Kubernetes 上下文丢失"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engCfg.Kubernetes = &agentcli.KubernetesTarget{
|
||||||
|
Client: ac.kubernetesCtx.client, ClusterName: ac.kubernetesCtx.cluster.Name,
|
||||||
|
Namespace: ac.kubernetesCtx.namespace, PodName: ac.kubernetesCtx.podName, Container: ac.kubernetesCtx.container,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
engCfg.SSH = ac.sshCfg
|
||||||
|
}
|
||||||
|
engine, err := agentcli.NewEngine(engCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ac.write(wsEvent{Type: "error", Message: "引擎初始化失败: " + err.Error()})
|
ac.write(wsEvent{Type: "error", Message: "引擎初始化失败: " + err.Error()})
|
||||||
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0)
|
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0)
|
||||||
@@ -311,8 +392,7 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe
|
|||||||
|
|
||||||
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
|
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
|
||||||
|
|
||||||
serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s",
|
targetInfo := ac.buildTargetInfo()
|
||||||
ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
|
|
||||||
|
|
||||||
msg := "任务已开始"
|
msg := "任务已开始"
|
||||||
if isContinue {
|
if isContinue {
|
||||||
@@ -326,7 +406,7 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe
|
|||||||
engine.Close()
|
engine.Close()
|
||||||
}()
|
}()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
result, err := engine.Run(ctx, query, serverInfo, prior, priorTokens)
|
result, err := engine.Run(ctx, query, targetInfo, prior, priorTokens)
|
||||||
if result != nil && len(result.History) > 0 {
|
if result != nil && len(result.History) > 0 {
|
||||||
ac.convHistory = result.History
|
ac.convHistory = result.History
|
||||||
ac.setTotalTokens(result.TotalTokens)
|
ac.setTotalTokens(result.TotalTokens)
|
||||||
@@ -405,10 +485,22 @@ func (ac *agentConn) onEvent(e core.Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildTargetInfo 构建 Prompt 目标上下文 // Logan.Li
|
||||||
|
func (ac *agentConn) buildTargetInfo() string {
|
||||||
|
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
|
||||||
|
return buildKubernetesTargetInfo(ac.kubernetesCtx)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s",
|
||||||
|
ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
|
||||||
|
}
|
||||||
|
|
||||||
// confirmFn 高危命令确认回调:推 confirm 事件并阻塞等前端回传 // Logan.Li
|
// confirmFn 高危命令确认回调:推 confirm 事件并阻塞等前端回传 // Logan.Li
|
||||||
func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) {
|
func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) {
|
||||||
confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano())
|
confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano())
|
||||||
cmd, _ := args["command"].(string)
|
cmd, _ := args["command"].(string)
|
||||||
|
if cmd == "" && (toolName == "kubernetes_action" || toolName == "k8s_action") {
|
||||||
|
cmd = fmt.Sprintf("%v %v", args["action"], args["name"])
|
||||||
|
}
|
||||||
ac.write(wsEvent{Type: "confirm", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args})
|
ac.write(wsEvent{Type: "confirm", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args})
|
||||||
|
|
||||||
ch := make(chan bool, 1)
|
ch := make(chan bool, 1)
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import (
|
|||||||
|
|
||||||
"ezops/middleware"
|
"ezops/middleware"
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
|
"ezops/pkg/agentcli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListSessions 会话列表(按当前用户隔离) // Logan.Li
|
// ListSessions 会话列表(按当前用户隔离) // Logan.Li
|
||||||
// GET /api/v1/aiops/agent-cli/sessions?server_id=&page=&page_size=
|
// GET /api/v1/aiops/agent-cli/sessions?mode=&server_id=&cluster_id=&namespace=&page=&page_size=
|
||||||
func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
|
func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
|
||||||
uid := middleware.GetUserID(c)
|
uid := middleware.GetUserID(c)
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
@@ -26,9 +27,23 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q := h.db.Model(&model.AgentCLISession{}).Where("user_id = ?", uid)
|
q := h.db.Model(&model.AgentCLISession{}).Where("user_id = ?", uid)
|
||||||
|
if mode := c.Query("mode"); mode != "" {
|
||||||
|
mode = agentcli.NormalizeRunMode(mode)
|
||||||
|
if mode == agentcli.RunModeKubernetes {
|
||||||
|
q = q.Where("mode IN ?", []string{agentcli.RunModeKubernetes, "k8s"})
|
||||||
|
} else {
|
||||||
|
q = q.Where("mode = ?", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
if sid := c.Query("server_id"); sid != "" {
|
if sid := c.Query("server_id"); sid != "" {
|
||||||
q = q.Where("server_id = ?", sid)
|
q = q.Where("server_id = ?", sid)
|
||||||
}
|
}
|
||||||
|
if cid := c.Query("cluster_id"); cid != "" {
|
||||||
|
q = q.Where("cluster_id = ?", cid)
|
||||||
|
}
|
||||||
|
if ns := c.Query("namespace"); ns != "" {
|
||||||
|
q = q.Where("namespace = ?", ns)
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
q.Count(&total)
|
q.Count(&total)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// agent_cli_kubernetes.go - Agent CLI Kubernetes 模式 WebSocket 支持 // Logan.Li
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"ezops/model"
|
||||||
|
k8sclient "ezops/pkg/kubernetesclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kubernetesAgentContext Kubernetes 模式运行上下文 // Logan.Li
|
||||||
|
type kubernetesAgentContext struct {
|
||||||
|
cluster model.KubernetesCluster
|
||||||
|
client *k8sclient.Client
|
||||||
|
namespace string
|
||||||
|
podName string
|
||||||
|
container string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKubernetesWSParams 从 WS 查询参数解析 Kubernetes 目标 // Logan.Li
|
||||||
|
func parseKubernetesWSParams(c *gin.Context) (clusterCode, namespace, podName, container string) {
|
||||||
|
clusterCode = strings.TrimSpace(c.Query("cluster"))
|
||||||
|
namespace = strings.TrimSpace(c.Query("namespace"))
|
||||||
|
podName = strings.TrimSpace(c.Query("pod"))
|
||||||
|
container = strings.TrimSpace(c.Query("container"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadKubernetesAgentContext 加载集群客户端并预检 // Logan.Li
|
||||||
|
func loadKubernetesAgentContext(db *gorm.DB, clusterCode, namespace, podName string) (*kubernetesAgentContext, error) {
|
||||||
|
if clusterCode == "" {
|
||||||
|
return nil, fmt.Errorf("缺少 cluster 参数")
|
||||||
|
}
|
||||||
|
if namespace == "" {
|
||||||
|
return nil, fmt.Errorf("缺少 namespace 参数")
|
||||||
|
}
|
||||||
|
var cluster model.KubernetesCluster
|
||||||
|
if err := db.Where("code = ?", clusterCode).First(&cluster).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("集群不存在: %s", clusterCode)
|
||||||
|
}
|
||||||
|
client, err := buildK8sClient(&cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("集群连接失败: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := client.ListNamespaces(); err != nil {
|
||||||
|
return nil, fmt.Errorf("集群 API 不可达: %w", err)
|
||||||
|
}
|
||||||
|
if podName != "" {
|
||||||
|
pods, err := client.ListPods()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取 Pod 列表失败: %w", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, p := range pods.Items {
|
||||||
|
if p.Metadata.Namespace == namespace && p.Metadata.Name == podName {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("Pod %s/%s 不存在", namespace, podName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &kubernetesAgentContext{
|
||||||
|
cluster: cluster, client: client,
|
||||||
|
namespace: namespace, podName: podName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildKubernetesTargetInfo 构建注入 Prompt 的集群上下文摘要 // Logan.Li
|
||||||
|
func buildKubernetesTargetInfo(ctx *kubernetesAgentContext) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("集群: %s(code=%s)| Namespace: %s", ctx.cluster.Name, ctx.cluster.Code, ctx.namespace))
|
||||||
|
if ctx.podName != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" | Pod: %s", ctx.podName))
|
||||||
|
if pods, err := ctx.client.ListPods(); err == nil {
|
||||||
|
for _, p := range pods.Items {
|
||||||
|
if p.Metadata.Namespace == ctx.namespace && p.Metadata.Name == ctx.podName {
|
||||||
|
image := ""
|
||||||
|
if len(p.Spec.Containers) > 0 {
|
||||||
|
image = p.Spec.Containers[0].Image
|
||||||
|
if ctx.container == "" {
|
||||||
|
ctx.container = p.Spec.Containers[0].Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" | 状态: %s | 节点: %s | 镜像: %s", p.Status.Phase, p.Spec.NodeName, image))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.WriteString(" | Pod: (未选定,集群/命名空间级任务)")
|
||||||
|
}
|
||||||
|
if ctx.container != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" | 容器: %s", ctx.container))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// kubernetesEvolveHostKey Evolver 记忆分桶键 // Logan.Li
|
||||||
|
func kubernetesEvolveHostKey(ctx *kubernetesAgentContext) string {
|
||||||
|
base := fmt.Sprintf("kubernetes://%s/%s", ctx.cluster.Code, ctx.namespace)
|
||||||
|
if ctx.podName != "" {
|
||||||
|
return base + "/" + ctx.podName
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ func (h *BillingHandler) ListBillings(c *gin.Context) {
|
|||||||
if accountName := c.Query("account_name"); accountName != "" {
|
if accountName := c.Query("account_name"); accountName != "" {
|
||||||
query = query.Where("account_name LIKE ?", "%"+accountName+"%")
|
query = query.Where("account_name LIKE ?", "%"+accountName+"%")
|
||||||
}
|
}
|
||||||
|
if accountID := c.Query("cloud_account_id"); accountID != "" {
|
||||||
|
query = query.Where("cloud_account_id = ?", accountID)
|
||||||
|
}
|
||||||
if startDate := c.Query("start_date"); startDate != "" {
|
if startDate := c.Query("start_date"); startDate != "" {
|
||||||
query = query.Where("billing_date >= ?", startDate)
|
query = query.Where("billing_date >= ?", startDate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"ezops/model"
|
||||||
|
"ezops/pkg/billingsync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncBillingRequest 同步账单请求(可选指定账号) // Logan.Li
|
||||||
|
type syncBillingRequest struct {
|
||||||
|
AccountID *uint `json:"account_id"`
|
||||||
|
Date string `json:"date"` // YYYY-MM-DD,默认昨日
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncYesterdayBillings 从云账号 AK 同步指定日期(默认昨日)账单 // Logan.Li
|
||||||
|
// POST /cloud-billings/sync
|
||||||
|
func (h *BillingHandler) SyncYesterdayBillings(c *gin.Context) {
|
||||||
|
var req syncBillingRequest
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
billingDate := req.Date
|
||||||
|
if billingDate == "" {
|
||||||
|
billingDate = billingsync.Yesterday()
|
||||||
|
}
|
||||||
|
if len(billingDate) != 10 {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "date 格式应为 YYYY-MM-DD"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := billingsync.SyncDate(h.db, billingDate, req.AccountID)
|
||||||
|
if result.Accounts == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "没有可同步的活跃云账号,请先在云账号管理中添加 AK"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("同步完成:新增 %d 条,更新 %d 条", result.Created, result.Updated)
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
msg += fmt.Sprintf(",%d 个账号失败", len(result.Errors))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: msg,
|
||||||
|
Data: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncAccountBilling 同步单个云账号账单
|
||||||
|
// POST /cloud-accounts/:id/sync-billing
|
||||||
|
func (h *BillingHandler) SyncAccountBilling(c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
if id <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效账号 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
billingDate := req.Date
|
||||||
|
if billingDate == "" {
|
||||||
|
billingDate = billingsync.Yesterday()
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := uint(id)
|
||||||
|
result := billingsync.SyncDate(h.db, billingDate, &uid)
|
||||||
|
if result.Accounts == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "云账号不存在或未激活"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("同步完成:新增 %d 条,更新 %d 条", result.Created, result.Updated)
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
msg = "同步失败: " + result.Errors[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: msg, Data: result})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"ezops/cloud"
|
"ezops/cloud"
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
|
"ezops/pkg/crypto"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -14,11 +15,12 @@ import (
|
|||||||
|
|
||||||
// CloudSyncHandler 云平台同步处理器
|
// CloudSyncHandler 云平台同步处理器
|
||||||
type CloudSyncHandler struct {
|
type CloudSyncHandler struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
crypt *crypto.SecretBox
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCloudSyncHandler(db *gorm.DB) *CloudSyncHandler {
|
func NewCloudSyncHandler(db *gorm.DB, masterKey string) *CloudSyncHandler {
|
||||||
return &CloudSyncHandler{db: db}
|
return &CloudSyncHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncRegions 同步云账号的区域列表到本地 regions 表
|
// SyncRegions 同步云账号的区域列表到本地 regions 表
|
||||||
@@ -167,8 +169,12 @@ func (h *CloudSyncHandler) DiscoverInstances(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// importRequest 导入主机请求
|
// importRequest 导入主机请求
|
||||||
type importRequest struct {
|
type importInstancesRequest struct {
|
||||||
Instances []cloud.CloudInstance `json:"instances"`
|
Instances []cloud.CloudInstance `json:"instances"`
|
||||||
|
DefaultPort int `json:"default_port"`
|
||||||
|
CredentialID *uint `json:"credential_id"` // 兼容旧字段:默认 SSH 凭据 // Logan.Li
|
||||||
|
SSHCredentialID *uint `json:"ssh_credential_id"` // Linux 默认 SSH 凭据 // Logan.Li
|
||||||
|
RDPCredentialID *uint `json:"rdp_credential_id"` // Windows 默认 RDP 凭据 // Logan.Li
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportInstances 将发现的云主机实例导入为本地服务器资产
|
// ImportInstances 将发现的云主机实例导入为本地服务器资产
|
||||||
@@ -182,12 +188,32 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req importRequest
|
var req importInstancesRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defaultPort := req.DefaultPort
|
||||||
|
if defaultPort <= 0 {
|
||||||
|
defaultPort = 22
|
||||||
|
}
|
||||||
|
|
||||||
|
sshCredentialID := req.SSHCredentialID
|
||||||
|
if sshCredentialID == nil {
|
||||||
|
sshCredentialID = req.CredentialID
|
||||||
|
}
|
||||||
|
sshCred, sshPlain, hasSSHCred, err := h.loadImportCredential(sshCredentialID, "ssh")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rdpCred, rdpPlain, hasRDPCred, err := h.loadImportCredential(req.RDPCredentialID, "rdp")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
imported := 0
|
imported := 0
|
||||||
skipped := 0
|
skipped := 0
|
||||||
|
|
||||||
@@ -216,9 +242,16 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
|||||||
regionID = ®ion.ID
|
regionID = ®ion.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
osName := "linux"
|
osName := inst.OSType
|
||||||
if inst.OSType != "" {
|
osType := normalizeImportOSType(inst.OSType, inst.InstanceName)
|
||||||
osName = inst.OSType
|
protocol := "ssh"
|
||||||
|
username := "root"
|
||||||
|
if osType == "windows" {
|
||||||
|
protocol = "rdp"
|
||||||
|
username = "Administrator"
|
||||||
|
}
|
||||||
|
if osName == "" {
|
||||||
|
osName = osType
|
||||||
}
|
}
|
||||||
status := "stopped"
|
status := "stopped"
|
||||||
if inst.Status == "Running" {
|
if inst.Status == "Running" {
|
||||||
@@ -245,8 +278,12 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
|||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
IP: ipAddr,
|
IP: ipAddr,
|
||||||
PublicIP: inst.PublicIP,
|
PublicIP: inst.PublicIP,
|
||||||
Port: 22,
|
Port: defaultPort,
|
||||||
Username: "root",
|
Username: username,
|
||||||
|
AuthType: "password",
|
||||||
|
OSType: osType,
|
||||||
|
Protocol: protocol,
|
||||||
|
RDPPort: 3389,
|
||||||
OS: osName,
|
OS: osName,
|
||||||
CPU: inst.CPU,
|
CPU: inst.CPU,
|
||||||
Memory: int64(inst.Memory),
|
Memory: int64(inst.Memory),
|
||||||
@@ -255,6 +292,11 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
|||||||
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
|
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
|
||||||
AgentStatus: "offline",
|
AgentStatus: "offline",
|
||||||
}
|
}
|
||||||
|
if protocol == "rdp" && hasRDPCred {
|
||||||
|
applyCredential(rdpCred, rdpPlain, &server)
|
||||||
|
} else if protocol != "rdp" && hasSSHCred {
|
||||||
|
applyCredential(sshCred, sshPlain, &server)
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.db.Create(&server).Error; err == nil {
|
if err := h.db.Create(&server).Error; err == nil {
|
||||||
imported++
|
imported++
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ func buildContent(req model.CreateSecretRequest) (string, string, error) {
|
|||||||
}
|
}
|
||||||
b, _ := json.Marshal(data)
|
b, _ := json.Marshal(data)
|
||||||
return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil
|
return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil
|
||||||
|
case "rdp":
|
||||||
|
if strings.TrimSpace(req.RDPUsername) == "" {
|
||||||
|
return "", "", fmt.Errorf("RDP 用户不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.RDPPassword) == "" {
|
||||||
|
return "", "", fmt.Errorf("RDP 密码不能为空")
|
||||||
|
}
|
||||||
|
security := req.RDPSecurity
|
||||||
|
if security == "" {
|
||||||
|
security = "nla"
|
||||||
|
}
|
||||||
|
data := map[string]string{
|
||||||
|
"username": strings.TrimSpace(req.RDPUsername),
|
||||||
|
"password": req.RDPPassword,
|
||||||
|
"domain": strings.TrimSpace(req.RDPDomain),
|
||||||
|
"security": security,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
return string(b), rdpSummary(req.RDPUsername, req.RDPDomain, security), nil
|
||||||
case "docker":
|
case "docker":
|
||||||
data := map[string]string{
|
data := map[string]string{
|
||||||
"server": req.DockerServer,
|
"server": req.DockerServer,
|
||||||
@@ -90,6 +109,20 @@ func buildContent(req model.CreateSecretRequest) (string, string, error) {
|
|||||||
return "{}", "", fmt.Errorf("unknown type: %s", req.SecretType)
|
return "{}", "", fmt.Errorf("unknown type: %s", req.SecretType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rdpSummary(username, domain, security string) string {
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
domain = strings.TrimSpace(domain)
|
||||||
|
security = strings.TrimSpace(security)
|
||||||
|
if security == "" || security == "<nil>" {
|
||||||
|
security = "nla"
|
||||||
|
}
|
||||||
|
account := username
|
||||||
|
if domain != "" && domain != "<nil>" {
|
||||||
|
account = username + "@" + domain
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Windows RDP %s / %s", account, strings.ToUpper(security))
|
||||||
|
}
|
||||||
|
|
||||||
// ────── List ──────
|
// ────── List ──────
|
||||||
|
|
||||||
func (h *CredentialHandler) ListCredentials(c *gin.Context) {
|
func (h *CredentialHandler) ListCredentials(c *gin.Context) {
|
||||||
@@ -117,9 +150,13 @@ func (h *CredentialHandler) ListCredentials(c *gin.Context) {
|
|||||||
db.Count(&total)
|
db.Count(&total)
|
||||||
|
|
||||||
page := query.Page
|
page := query.Page
|
||||||
if page < 1 { page = 1 }
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
pageSize := query.PageSize
|
pageSize := query.PageSize
|
||||||
if pageSize < 1 || pageSize > 100 { pageSize = 20 }
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
var creds []model.Credential
|
var creds []model.Credential
|
||||||
db.Order("updated_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&creds)
|
db.Order("updated_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&creds)
|
||||||
@@ -175,7 +212,9 @@ func (h *CredentialHandler) CreateCredential(c *gin.Context) {
|
|||||||
tagsJSON, _ := json.Marshal(req.Tags)
|
tagsJSON, _ := json.Marshal(req.Tags)
|
||||||
|
|
||||||
isActive := true
|
isActive := true
|
||||||
if req.IsActive != nil { isActive = *req.IsActive }
|
if req.IsActive != nil {
|
||||||
|
isActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
cred := model.Credential{
|
cred := model.Credential{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -219,7 +258,9 @@ func (h *CredentialHandler) GetCredential(c *gin.Context) {
|
|||||||
json.Unmarshal([]byte(plain), &contentData)
|
json.Unmarshal([]byte(plain), &contentData)
|
||||||
|
|
||||||
tags := []string{}
|
tags := []string{}
|
||||||
if cred.Tags != "" { json.Unmarshal([]byte(cred.Tags), &tags) }
|
if cred.Tags != "" {
|
||||||
|
json.Unmarshal([]byte(cred.Tags), &tags)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||||
"id": cred.ID,
|
"id": cred.ID,
|
||||||
@@ -255,11 +296,21 @@ func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
|
|||||||
|
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
|
|
||||||
if req.Name != nil { updates["name"] = *req.Name }
|
if req.Name != nil {
|
||||||
if req.VariableName != nil { updates["variable_name"] = *req.VariableName }
|
updates["name"] = *req.Name
|
||||||
if req.Description != nil { updates["description"] = *req.Description }
|
}
|
||||||
if req.ExpiresAt != nil { updates["expires_at"] = req.ExpiresAt }
|
if req.VariableName != nil {
|
||||||
if req.IsActive != nil { updates["is_active"] = *req.IsActive }
|
updates["variable_name"] = *req.VariableName
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
updates["description"] = *req.Description
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != nil {
|
||||||
|
updates["expires_at"] = req.ExpiresAt
|
||||||
|
}
|
||||||
|
if req.IsActive != nil {
|
||||||
|
updates["is_active"] = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
if len(req.Tags) > 0 {
|
if len(req.Tags) > 0 {
|
||||||
b, _ := json.Marshal(req.Tags)
|
b, _ := json.Marshal(req.Tags)
|
||||||
@@ -272,7 +323,9 @@ func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
|
|||||||
oldPlain, _ := h.crypt.Decrypt(cred.Content)
|
oldPlain, _ := h.crypt.Decrypt(cred.Content)
|
||||||
var old map[string]interface{}
|
var old map[string]interface{}
|
||||||
json.Unmarshal([]byte(oldPlain), &old)
|
json.Unmarshal([]byte(oldPlain), &old)
|
||||||
if old == nil { old = map[string]interface{}{} }
|
if old == nil {
|
||||||
|
old = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
mergeUpdate(req, old)
|
mergeUpdate(req, old)
|
||||||
|
|
||||||
@@ -297,6 +350,7 @@ func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
|
|||||||
|
|
||||||
func hasTypeFieldUpdate(req model.UpdateSecretRequest) bool {
|
func hasTypeFieldUpdate(req model.UpdateSecretRequest) bool {
|
||||||
return req.SSHPrivateKey != nil || req.SSHPublicKey != nil || req.SSHUser != nil || req.SSHPort != nil || req.SSHPassword != nil ||
|
return req.SSHPrivateKey != nil || req.SSHPublicKey != nil || req.SSHUser != nil || req.SSHPort != nil || req.SSHPassword != nil ||
|
||||||
|
req.RDPUsername != nil || req.RDPPassword != nil || req.RDPDomain != nil || req.RDPSecurity != nil ||
|
||||||
req.DockerServer != nil || req.DockerUsername != nil || req.DockerPassword != nil ||
|
req.DockerServer != nil || req.DockerUsername != nil || req.DockerPassword != nil ||
|
||||||
req.TLSCert != nil || req.TLSKey != nil || req.TLSCA != nil ||
|
req.TLSCert != nil || req.TLSKey != nil || req.TLSCA != nil ||
|
||||||
req.AKSKProvider != nil || req.AKSKAccessKey != nil || req.AKSKSecretKey != nil || req.AKSKRegion != nil ||
|
req.AKSKProvider != nil || req.AKSKAccessKey != nil || req.AKSKSecretKey != nil || req.AKSKRegion != nil ||
|
||||||
@@ -306,37 +360,105 @@ func hasTypeFieldUpdate(req model.UpdateSecretRequest) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mergeUpdate(req model.UpdateSecretRequest, old map[string]interface{}) {
|
func mergeUpdate(req model.UpdateSecretRequest, old map[string]interface{}) {
|
||||||
if req.SSHPrivateKey != nil { old["private_key"] = *req.SSHPrivateKey }
|
if req.SSHPrivateKey != nil {
|
||||||
if req.SSHPublicKey != nil { old["public_key"] = *req.SSHPublicKey }
|
old["private_key"] = *req.SSHPrivateKey
|
||||||
if req.SSHUser != nil { old["ssh_user"] = *req.SSHUser }
|
}
|
||||||
if req.SSHPort != nil { old["ssh_port"] = *req.SSHPort }
|
if req.SSHPublicKey != nil {
|
||||||
if req.SSHPassword != nil { old["password"] = *req.SSHPassword }
|
old["public_key"] = *req.SSHPublicKey
|
||||||
if req.DockerServer != nil { old["server"] = *req.DockerServer }
|
}
|
||||||
if req.DockerUsername != nil { old["username"] = *req.DockerUsername }
|
if req.SSHUser != nil {
|
||||||
if req.DockerPassword != nil { old["password"] = *req.DockerPassword }
|
old["ssh_user"] = *req.SSHUser
|
||||||
if req.TLSCert != nil { old["cert"] = *req.TLSCert }
|
}
|
||||||
if req.TLSKey != nil { old["key"] = *req.TLSKey }
|
if req.SSHPort != nil {
|
||||||
if req.TLSCA != nil { old["ca"] = *req.TLSCA }
|
old["ssh_port"] = *req.SSHPort
|
||||||
if req.AKSKProvider != nil { old["provider"] = *req.AKSKProvider }
|
}
|
||||||
if req.AKSKAccessKey != nil { old["access_key"] = *req.AKSKAccessKey }
|
if req.SSHPassword != nil {
|
||||||
if req.AKSKSecretKey != nil { old["secret_key"] = *req.AKSKSecretKey }
|
old["password"] = *req.SSHPassword
|
||||||
if req.AKSKRegion != nil { old["region"] = *req.AKSKRegion }
|
}
|
||||||
if req.DBType != nil { old["db_type"] = *req.DBType }
|
if req.RDPUsername != nil {
|
||||||
if req.DBHost != nil { old["host"] = *req.DBHost }
|
old["username"] = *req.RDPUsername
|
||||||
if req.DBPort != nil { old["port"] = *req.DBPort }
|
}
|
||||||
if req.DBName != nil { old["db_name"] = *req.DBName }
|
if req.RDPPassword != nil {
|
||||||
if req.DBUsername != nil { old["username"] = *req.DBUsername }
|
old["password"] = *req.RDPPassword
|
||||||
if req.DBPassword != nil { old["password"] = *req.DBPassword }
|
}
|
||||||
if req.APIKey != nil { old["api_key"] = *req.APIKey }
|
if req.RDPDomain != nil {
|
||||||
if req.APIEndpoint != nil { old["endpoint"] = *req.APIEndpoint }
|
old["domain"] = *req.RDPDomain
|
||||||
if req.GenericKey != nil { old["generic_key"] = *req.GenericKey }
|
}
|
||||||
if req.GenericValue != nil { old["generic_value"] = *req.GenericValue }
|
if req.RDPSecurity != nil {
|
||||||
|
security := strings.TrimSpace(*req.RDPSecurity)
|
||||||
|
if security == "" {
|
||||||
|
security = "nla"
|
||||||
|
}
|
||||||
|
old["security"] = security
|
||||||
|
}
|
||||||
|
if req.DockerServer != nil {
|
||||||
|
old["server"] = *req.DockerServer
|
||||||
|
}
|
||||||
|
if req.DockerUsername != nil {
|
||||||
|
old["username"] = *req.DockerUsername
|
||||||
|
}
|
||||||
|
if req.DockerPassword != nil {
|
||||||
|
old["password"] = *req.DockerPassword
|
||||||
|
}
|
||||||
|
if req.TLSCert != nil {
|
||||||
|
old["cert"] = *req.TLSCert
|
||||||
|
}
|
||||||
|
if req.TLSKey != nil {
|
||||||
|
old["key"] = *req.TLSKey
|
||||||
|
}
|
||||||
|
if req.TLSCA != nil {
|
||||||
|
old["ca"] = *req.TLSCA
|
||||||
|
}
|
||||||
|
if req.AKSKProvider != nil {
|
||||||
|
old["provider"] = *req.AKSKProvider
|
||||||
|
}
|
||||||
|
if req.AKSKAccessKey != nil {
|
||||||
|
old["access_key"] = *req.AKSKAccessKey
|
||||||
|
}
|
||||||
|
if req.AKSKSecretKey != nil {
|
||||||
|
old["secret_key"] = *req.AKSKSecretKey
|
||||||
|
}
|
||||||
|
if req.AKSKRegion != nil {
|
||||||
|
old["region"] = *req.AKSKRegion
|
||||||
|
}
|
||||||
|
if req.DBType != nil {
|
||||||
|
old["db_type"] = *req.DBType
|
||||||
|
}
|
||||||
|
if req.DBHost != nil {
|
||||||
|
old["host"] = *req.DBHost
|
||||||
|
}
|
||||||
|
if req.DBPort != nil {
|
||||||
|
old["port"] = *req.DBPort
|
||||||
|
}
|
||||||
|
if req.DBName != nil {
|
||||||
|
old["db_name"] = *req.DBName
|
||||||
|
}
|
||||||
|
if req.DBUsername != nil {
|
||||||
|
old["username"] = *req.DBUsername
|
||||||
|
}
|
||||||
|
if req.DBPassword != nil {
|
||||||
|
old["password"] = *req.DBPassword
|
||||||
|
}
|
||||||
|
if req.APIKey != nil {
|
||||||
|
old["api_key"] = *req.APIKey
|
||||||
|
}
|
||||||
|
if req.APIEndpoint != nil {
|
||||||
|
old["endpoint"] = *req.APIEndpoint
|
||||||
|
}
|
||||||
|
if req.GenericKey != nil {
|
||||||
|
old["generic_key"] = *req.GenericKey
|
||||||
|
}
|
||||||
|
if req.GenericValue != nil {
|
||||||
|
old["generic_value"] = *req.GenericValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSummaryFromRaw(data map[string]interface{}, stype string) string {
|
func buildSummaryFromRaw(data map[string]interface{}, stype string) string {
|
||||||
switch stype {
|
switch stype {
|
||||||
case "ssh":
|
case "ssh":
|
||||||
return fmt.Sprintf("SSH %v@%v", data["ssh_user"], data["ssh_port"])
|
return fmt.Sprintf("SSH %v@%v", data["ssh_user"], data["ssh_port"])
|
||||||
|
case "rdp":
|
||||||
|
return rdpSummary(fmt.Sprint(data["username"]), fmt.Sprint(data["domain"]), fmt.Sprint(data["security"]))
|
||||||
case "docker":
|
case "docker":
|
||||||
return fmt.Sprintf("%v", data["server"])
|
return fmt.Sprintf("%v", data["server"])
|
||||||
case "aksk":
|
case "aksk":
|
||||||
@@ -391,10 +513,14 @@ func (h *CredentialHandler) GetTags(c *gin.Context) {
|
|||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var tags []string
|
var tags []string
|
||||||
if json.Unmarshal([]byte(r), &tags) == nil {
|
if json.Unmarshal([]byte(r), &tags) == nil {
|
||||||
for _, t := range tags { tagSet[t] = true }
|
for _, t := range tags {
|
||||||
|
tagSet[t] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tags := make([]string, 0, len(tagSet))
|
tags := make([]string, 0, len(tagSet))
|
||||||
for t := range tagSet { tags = append(tags, t) }
|
for t := range tagSet {
|
||||||
|
tags = append(tags, t)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: tags})
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: tags})
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,23 @@ func applyCredential(cred model.Credential, plain string, server *model.Server)
|
|||||||
server.SSHKey = privateKey
|
server.SSHKey = privateKey
|
||||||
server.AuthType = "key"
|
server.AuthType = "key"
|
||||||
server.Password = ""
|
server.Password = ""
|
||||||
|
server.Protocol = "ssh"
|
||||||
|
if server.OSType == "" {
|
||||||
|
server.OSType = "linux"
|
||||||
|
}
|
||||||
|
} else if cred.SecretType == "rdp" {
|
||||||
|
var rdpc model.RDPSecretContent
|
||||||
|
if json.Unmarshal([]byte(plain), &rdpc) == nil && rdpc.Username != "" {
|
||||||
|
server.Username = rdpc.Username
|
||||||
|
}
|
||||||
|
server.Password = plain
|
||||||
|
server.AuthType = "password"
|
||||||
|
server.SSHKey = ""
|
||||||
|
server.Protocol = "rdp"
|
||||||
|
server.OSType = "windows"
|
||||||
|
if server.RDPPort == 0 {
|
||||||
|
server.RDPPort = 3389
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
server.Password = plain
|
server.Password = plain
|
||||||
server.AuthType = "password"
|
server.AuthType = "password"
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/middleware"
|
||||||
|
"ezops/model"
|
||||||
|
"ezops/pkg/crypto"
|
||||||
|
rdpengine "ezops/pkg/rdp"
|
||||||
|
"ezops/pkg/wshub"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RDPHandler 提供后端渲染型 RDP 会话,浏览器只负责 Canvas 与输入事件 // Logan.Li
|
||||||
|
type RDPHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
crypt *crypto.SecretBox
|
||||||
|
wsMgr *wshub.WSManager
|
||||||
|
engine rdpengine.Engine
|
||||||
|
clients sync.Map // sessionID -> context.CancelFunc // Logan.Li
|
||||||
|
live sync.Map // sessionID -> *rdpLiveBroadcaster // Logan.Li
|
||||||
|
}
|
||||||
|
|
||||||
|
const rdpAuditMaxText = 500
|
||||||
|
const rdpMaxWidth = 1024
|
||||||
|
const rdpMaxHeight = 800
|
||||||
|
|
||||||
|
func NewRDPHandler(db *gorm.DB, masterKey string, wsMgr *wshub.WSManager) *RDPHandler {
|
||||||
|
return &RDPHandler{
|
||||||
|
db: db,
|
||||||
|
crypt: crypto.NewSecretBox(masterKey),
|
||||||
|
wsMgr: wsMgr,
|
||||||
|
engine: rdpengine.NewEngine(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPHandler) KillSession(sessionID uint) bool {
|
||||||
|
if val, ok := h.clients.Load(sessionID); ok {
|
||||||
|
cancel := val.(context.CancelFunc)
|
||||||
|
cancel()
|
||||||
|
h.clients.Delete(sessionID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPHandler) ServeLive(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("sessionId"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的 RDP 会话ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var session model.RDPSession
|
||||||
|
if err := h.db.First(&session, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "RDP 会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.Status != "active" {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "RDP 会话已结束,请使用历史回放"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val, ok := h.live.Load(uint(id))
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "RDP 实时流不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, info, err := h.wsMgr.Upgrade(c, wshub.TypeAudit, middleware.GetUserID(c), middleware.GetUsername(c))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer h.wsMgr.Unregister(info)
|
||||||
|
hub := val.(*rdpLiveBroadcaster)
|
||||||
|
ch := hub.subscribe()
|
||||||
|
defer hub.unsubscribe(ch)
|
||||||
|
|
||||||
|
_ = writeRDPFrame(h.wsMgr, conn, rdpengine.ServerFrame{
|
||||||
|
Type: "session",
|
||||||
|
Message: strconv.FormatUint(uint64(session.ID), 10),
|
||||||
|
Width: session.Width,
|
||||||
|
Height: session.Height,
|
||||||
|
})
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case frame, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := writeRDPFrame(h.wsMgr, conn, frame); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPHandler) Serve(c *gin.Context) {
|
||||||
|
serverID := c.Query("id")
|
||||||
|
if serverID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少服务器ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
width := clampPositiveInt(c.Query("width"), 1280, rdpMaxWidth)
|
||||||
|
height := clampPositiveInt(c.Query("height"), 800, rdpMaxHeight)
|
||||||
|
|
||||||
|
var server model.Server
|
||||||
|
if err := h.db.First(&server, serverID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "服务器不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalizeServerRemoteAccess(&server)
|
||||||
|
if !isRDPServer(server) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该资产未配置为 RDP/Windows"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rdpCred, err := h.loadRDPCredential(server)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
username := middleware.GetUsername(c)
|
||||||
|
conn, info, err := h.wsMgr.Upgrade(c, "rdp", userID, username)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[RDP] ws connected session pending server=%s ip=%s user=%s size=%dx%d", server.Hostname, server.IP, username, width, height)
|
||||||
|
defer h.wsMgr.Unregister(info)
|
||||||
|
|
||||||
|
var userIDPtr *uint
|
||||||
|
if userID > 0 {
|
||||||
|
userIDPtr = &userID
|
||||||
|
}
|
||||||
|
port := server.RDPPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 3389
|
||||||
|
}
|
||||||
|
session := model.RDPSession{
|
||||||
|
UserID: userIDPtr,
|
||||||
|
Username: username,
|
||||||
|
ServerID: server.ID,
|
||||||
|
ServerName: server.Hostname,
|
||||||
|
ServerIP: server.IP,
|
||||||
|
ServerPort: port,
|
||||||
|
ClientIP: c.ClientIP(),
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Status: "active",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&session).Error; err != nil {
|
||||||
|
_ = conn.WriteJSON(rdpengine.ServerFrame{Type: "error", Message: "创建 RDP 会话失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(c.Request.Context())
|
||||||
|
h.clients.Store(session.ID, cancel)
|
||||||
|
liveHub := newRDPLiveBroadcaster()
|
||||||
|
h.live.Store(session.ID, liveHub)
|
||||||
|
defer func() {
|
||||||
|
cancel()
|
||||||
|
h.clients.Delete(session.ID)
|
||||||
|
h.live.Delete(session.ID)
|
||||||
|
liveHub.close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
events := make(chan rdpengine.ClientEvent, 128)
|
||||||
|
sink := &wsFrameSink{conn: conn, mgr: h.wsMgr, recorder: rdpengine.NewMemoryRecorder(500), live: liveHub, sessionID: session.ID}
|
||||||
|
_ = sink.SendFrame(rdpengine.ServerFrame{Type: "session", Message: strconv.Itoa(int(session.ID)), Width: width, Height: height})
|
||||||
|
engineDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
log.Printf("[RDP] engine start session=%d server=%s:%d size=%dx%d", session.ID, server.IP, port, width, height)
|
||||||
|
engineDone <- h.engine.Run(ctx, rdpengine.Config{
|
||||||
|
Host: server.IP,
|
||||||
|
Port: port,
|
||||||
|
Username: rdpCred.Username,
|
||||||
|
Password: rdpCred.Password,
|
||||||
|
Domain: rdpCred.Domain,
|
||||||
|
Security: rdpCred.Security,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}, sink, events)
|
||||||
|
}()
|
||||||
|
|
||||||
|
status := "closed"
|
||||||
|
errMsg := ""
|
||||||
|
defer func() {
|
||||||
|
cancel()
|
||||||
|
end := time.Now()
|
||||||
|
duration := int(end.Sub(session.StartTime).Seconds())
|
||||||
|
recordData := sink.recorder.JSON()
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"duration": duration,
|
||||||
|
"end_time": end,
|
||||||
|
"record_data": recordData,
|
||||||
|
"record_size": len(recordData),
|
||||||
|
}
|
||||||
|
if errMsg != "" {
|
||||||
|
updates["error"] = errMsg
|
||||||
|
}
|
||||||
|
_ = h.db.Model(&model.RDPSession{}).Where("id = ?", session.ID).Updates(updates).Error
|
||||||
|
}()
|
||||||
|
|
||||||
|
audit := newRDPOperationRecorder(h.db, session, userIDPtr, username)
|
||||||
|
defer audit.Flush()
|
||||||
|
|
||||||
|
readDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(readDone)
|
||||||
|
for {
|
||||||
|
var event rdpengine.ClientEvent
|
||||||
|
if err := conn.ReadJSON(&event); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audit.Record(event)
|
||||||
|
enqueueRDPEvent(events, event)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-engineDone:
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
status = "failed"
|
||||||
|
errMsg = err.Error()
|
||||||
|
log.Printf("[RDP] engine failed session=%d err=%v", session.ID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[RDP] engine closed session=%d err=%v", session.ID, err)
|
||||||
|
}
|
||||||
|
case <-readDone:
|
||||||
|
log.Printf("[RDP] ws read closed session=%d", session.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueueRDPEvent(events chan rdpengine.ClientEvent, event rdpengine.ClientEvent) {
|
||||||
|
select {
|
||||||
|
case events <- event:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if event.Type == "mousemove" || event.Type == "mouse_move" {
|
||||||
|
// 队列已满时丢弃当前移动事件,避免旧坐标堆积拖慢点击/拖拽响应。 // Logan.Li
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 关键输入不能被旧 mousemove 堵住,优先清理一批移动事件后再投递。 // Logan.Li
|
||||||
|
for i := 0; i < cap(events)/2; i++ {
|
||||||
|
select {
|
||||||
|
case queued := <-events:
|
||||||
|
if queued.Type != "mousemove" && queued.Type != "mouse_move" {
|
||||||
|
select {
|
||||||
|
case events <- queued:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
i = cap(events)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case events <- event:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case events <- event:
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
log.Printf("[RDP] input queue congested, drop critical event type=%s", event.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type rdpOperationRecorder struct {
|
||||||
|
db *gorm.DB
|
||||||
|
session model.RDPSession
|
||||||
|
userID *uint
|
||||||
|
username string
|
||||||
|
inputBuffer string
|
||||||
|
lastInput time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRDPOperationRecorder(db *gorm.DB, session model.RDPSession, userID *uint, username string) *rdpOperationRecorder {
|
||||||
|
return &rdpOperationRecorder{db: db, session: session, userID: userID, username: username}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rdpOperationRecorder) Record(event rdpengine.ClientEvent) {
|
||||||
|
now := time.Now()
|
||||||
|
if r.inputBuffer != "" && now.Sub(r.lastInput) > 2*time.Second {
|
||||||
|
r.Flush()
|
||||||
|
}
|
||||||
|
switch event.Type {
|
||||||
|
case "key":
|
||||||
|
r.recordKey(event, now)
|
||||||
|
case "mousemove", "mouse_move", "mousedown", "mouse_down", "mouseup", "mouse_up", "wheel":
|
||||||
|
return // 鼠标轨迹只进图形录屏,不写命令审计,避免 RDP 命令表噪声。 // Logan.Li
|
||||||
|
case "clipboard":
|
||||||
|
r.Flush()
|
||||||
|
r.create("[RDP粘贴] " + truncateAuditText(event.Text, rdpAuditMaxText))
|
||||||
|
case "file_upload":
|
||||||
|
r.Flush()
|
||||||
|
r.create("[RDP上传] " + truncateAuditText(event.Text, rdpAuditMaxText))
|
||||||
|
case "file_download":
|
||||||
|
r.Flush()
|
||||||
|
r.create("[RDP下载] " + truncateAuditText(event.Text, rdpAuditMaxText))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rdpOperationRecorder) recordKey(event rdpengine.ClientEvent, now time.Time) {
|
||||||
|
if !event.Down {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Key == "Control" || event.Key == "Shift" || event.Key == "Alt" || event.Key == "Meta" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Code == "Enter" || event.Code == "NumpadEnter" {
|
||||||
|
if strings.TrimSpace(r.inputBuffer) != "" {
|
||||||
|
r.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.create("[RDP按键] Enter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Code == "Backspace" {
|
||||||
|
if len(r.inputBuffer) > 0 {
|
||||||
|
r.inputBuffer = r.inputBuffer[:len(r.inputBuffer)-1]
|
||||||
|
r.lastInput = now
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(event.Key) == 1 {
|
||||||
|
r.inputBuffer += event.Key
|
||||||
|
r.lastInput = now
|
||||||
|
if len(r.inputBuffer) >= rdpAuditMaxText {
|
||||||
|
r.Flush()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isAuditableRDPKey(event.Key) {
|
||||||
|
r.Flush()
|
||||||
|
r.create("[RDP按键] " + event.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAuditableRDPKey 只保留对 Windows 操作有审计意义的非文本按键。 // Logan.Li
|
||||||
|
func isAuditableRDPKey(key string) bool {
|
||||||
|
switch key {
|
||||||
|
case "Tab", "Escape", "Delete", "Home", "End", "PageUp", "PageDown":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(key, "F") {
|
||||||
|
n, err := strconv.Atoi(strings.TrimPrefix(key, "F"))
|
||||||
|
return err == nil && n >= 1 && n <= 24
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rdpOperationRecorder) Flush() {
|
||||||
|
if strings.TrimSpace(r.inputBuffer) == "" {
|
||||||
|
r.inputBuffer = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.create("[RDP输入] " + truncateAuditText(r.inputBuffer, rdpAuditMaxText))
|
||||||
|
r.inputBuffer = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rdpOperationRecorder) create(command string) {
|
||||||
|
cmd := model.SessionCommand{
|
||||||
|
SessionID: r.session.ID,
|
||||||
|
SessionType: "rdp",
|
||||||
|
UserID: r.userID,
|
||||||
|
Username: r.username,
|
||||||
|
ServerID: r.session.ServerID,
|
||||||
|
ServerName: r.session.ServerName,
|
||||||
|
Command: command,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
_ = r.db.Create(&cmd).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPHandler) loadRDPCredential(server model.Server) (model.RDPSecretContent, error) {
|
||||||
|
return loadRDPCredential(h.crypt, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePositiveInt(raw string, fallback int) int {
|
||||||
|
v, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampPositiveInt(raw string, fallback int, max int) int {
|
||||||
|
v := parsePositiveInt(raw, fallback)
|
||||||
|
if max > 0 && v > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsFrameSink struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
mgr *wshub.WSManager
|
||||||
|
mu sync.Mutex
|
||||||
|
recorder *rdpengine.MemoryRecorder
|
||||||
|
live *rdpLiveBroadcaster
|
||||||
|
sessionID uint
|
||||||
|
bitmaps int
|
||||||
|
lastLog time.Time
|
||||||
|
lastRecord time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *wsFrameSink) SendFrame(frame rdpengine.ServerFrame) error {
|
||||||
|
s.recorder.Append(frame)
|
||||||
|
if s.live != nil {
|
||||||
|
s.live.publish(frame)
|
||||||
|
}
|
||||||
|
if snap, ok := s.snapshotFrame(frame); ok {
|
||||||
|
s.recorder.AppendSnapshot(snap)
|
||||||
|
}
|
||||||
|
if frame.Type == "bitmap" {
|
||||||
|
s.bitmaps++
|
||||||
|
if s.bitmaps <= 3 || time.Since(s.lastLog) > 5*time.Second {
|
||||||
|
log.Printf("[RDP] bitmap session=%d count=%d rect=%dx%d@%d,%d image=%d rgba=%d", s.sessionID, s.bitmaps, frame.Width, frame.Height, frame.X, frame.Y, len(frame.Image), len(frame.RGBA))
|
||||||
|
s.lastLog = time.Now()
|
||||||
|
}
|
||||||
|
} else if frame.Type == "status" || frame.Type == "error" || frame.Type == "session" {
|
||||||
|
log.Printf("[RDP] frame session=%d type=%s msg=%q size=%dx%d", s.sessionID, frame.Type, frame.Message, frame.Width, frame.Height)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return writeRDPFrame(s.mgr, s.conn, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *wsFrameSink) snapshotFrame(frame rdpengine.ServerFrame) (rdpengine.ServerFrame, bool) {
|
||||||
|
if frame.Type != "bitmap" || len(frame.RGBA) == 0 || frame.Width <= 0 || frame.Height <= 0 {
|
||||||
|
return rdpengine.ServerFrame{}, false
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if !s.lastRecord.IsZero() && now.Sub(s.lastRecord) < 500*time.Millisecond {
|
||||||
|
return rdpengine.ServerFrame{}, false
|
||||||
|
}
|
||||||
|
s.lastRecord = now
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height))
|
||||||
|
copy(img.Pix, frame.RGBA)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return rdpengine.ServerFrame{}, false
|
||||||
|
}
|
||||||
|
frame.RGBA = nil
|
||||||
|
frame.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
return frame, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRDPFrame(mgr *wshub.WSManager, conn *websocket.Conn, frame rdpengine.ServerFrame) error {
|
||||||
|
mgr.SetWriteDeadline(conn)
|
||||||
|
if frame.Type == "bitmap" && len(frame.RGBA) > 0 {
|
||||||
|
return conn.WriteMessage(websocket.BinaryMessage, encodeRDPBitmapBinary(frame))
|
||||||
|
}
|
||||||
|
return conn.WriteJSON(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeRDPBitmapBinary(frame rdpengine.ServerFrame) []byte {
|
||||||
|
headerLen := 20
|
||||||
|
out := make([]byte, headerLen+len(frame.RGBA))
|
||||||
|
copy(out[0:4], []byte{'R', 'D', 'P', 'R'})
|
||||||
|
binary.LittleEndian.PutUint32(out[4:8], uint32(frame.X))
|
||||||
|
binary.LittleEndian.PutUint32(out[8:12], uint32(frame.Y))
|
||||||
|
binary.LittleEndian.PutUint32(out[12:16], uint32(frame.Width))
|
||||||
|
binary.LittleEndian.PutUint32(out[16:20], uint32(frame.Height))
|
||||||
|
copy(out[headerLen:], frame.RGBA)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"ezops/model"
|
||||||
|
"ezops/pkg/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadRDPCredential 解密并解析 Windows 服务器 RDP 凭据 // Logan.Li
|
||||||
|
func loadRDPCredential(crypt *crypto.SecretBox, server model.Server) (model.RDPSecretContent, error) {
|
||||||
|
var cred model.RDPSecretContent
|
||||||
|
if server.Password == "" {
|
||||||
|
return cred, errors.New("该 Windows 资产未关联 RDP 凭据")
|
||||||
|
}
|
||||||
|
plain := server.Password
|
||||||
|
if dec, err := crypt.Decrypt(server.Password); err == nil {
|
||||||
|
plain = dec
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(plain), &cred); err != nil {
|
||||||
|
cred.Username = server.Username
|
||||||
|
cred.Password = plain
|
||||||
|
}
|
||||||
|
if cred.Username == "" {
|
||||||
|
cred.Username = server.Username
|
||||||
|
}
|
||||||
|
if cred.Security == "" {
|
||||||
|
cred.Security = "nla"
|
||||||
|
}
|
||||||
|
if cred.Username == "" || cred.Password == "" {
|
||||||
|
return cred, errors.New("RDP 凭据缺少用户名或密码")
|
||||||
|
}
|
||||||
|
return cred, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/model"
|
||||||
|
"ezops/pkg/crypto"
|
||||||
|
"ezops/pkg/rdpfilestage"
|
||||||
|
"ezops/pkg/winsmb"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RDPFileHandler 通过 SMB 管理共享实现 RDP 虚拟 Download 盘文件传输 // Logan.Li
|
||||||
|
type RDPFileHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
crypt *crypto.SecretBox
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRDPFileHandler(db *gorm.DB, masterKey string) *RDPFileHandler {
|
||||||
|
return &RDPFileHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPFileHandler) loadAuth(serverID uint) (winsmb.Auth, error) {
|
||||||
|
var server model.Server
|
||||||
|
if err := h.db.First(&server, serverID).Error; err != nil {
|
||||||
|
return winsmb.Auth{}, err
|
||||||
|
}
|
||||||
|
normalizeServerRemoteAccess(&server)
|
||||||
|
if !isRDPServer(server) {
|
||||||
|
return winsmb.Auth{}, errors.New("该资产未配置为 RDP/Windows")
|
||||||
|
}
|
||||||
|
cred, err := loadRDPCredential(h.crypt, server)
|
||||||
|
if err != nil {
|
||||||
|
return winsmb.Auth{}, err
|
||||||
|
}
|
||||||
|
return winsmb.Auth{
|
||||||
|
Host: server.IP,
|
||||||
|
Domain: cred.Domain,
|
||||||
|
Username: cred.Username,
|
||||||
|
Password: cred.Password,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPFileHandler) ListFiles(c *gin.Context) {
|
||||||
|
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth, err := h.loadAuth(uint(serverID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := winsmb.ListDownload(auth)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type item struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
ModTime string `json:"mod_time"`
|
||||||
|
}
|
||||||
|
list := make([]item, 0, len(entries))
|
||||||
|
for _, ent := range entries {
|
||||||
|
if ent.IsDir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, item{
|
||||||
|
Name: ent.Name,
|
||||||
|
Size: ent.Size,
|
||||||
|
IsDir: ent.IsDir,
|
||||||
|
ModTime: ent.ModTime.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Response{
|
||||||
|
Code: 200,
|
||||||
|
Data: gin.H{
|
||||||
|
"root": winsmb.DownloadFullPath,
|
||||||
|
"list": list,
|
||||||
|
"total": len(list),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPFileHandler) UploadFile(c *gin.Context) {
|
||||||
|
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fileHeader.Size > 100<<20 {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "单文件不能超过 100MB"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth, err := h.loadAuth(uint(serverID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := winsmb.Upload(auth, fileHeader.Filename, data); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "上传成功",
|
||||||
|
Data: gin.H{
|
||||||
|
"name": fileHeader.Filename,
|
||||||
|
"path": winsmb.DownloadFullPath + `\` + fileHeader.Filename,
|
||||||
|
"size": fileHeader.Size,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPFileHandler) DownloadFile(c *gin.Context) {
|
||||||
|
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := c.Query("name")
|
||||||
|
if name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少文件名"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth, err := h.loadAuth(uint(serverID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := winsmb.Download(auth, name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||||
|
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StageUpload SMB 不可用时:文件暂存 AIOps,返回可由 Windows 拉取的一次性 URL // Logan.Li
|
||||||
|
func (h *RDPFileHandler) StageUpload(c *gin.Context) {
|
||||||
|
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fileHeader.Size > 50<<20 {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "回退模式单文件不能超过 50MB"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.loadAuth(uint(serverID)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := rdpfilestage.Put(fileHeader.Filename, data, 30*time.Minute)
|
||||||
|
base := strings.TrimRight(publicHTTPBase(c), "/")
|
||||||
|
downloadURL := fmt.Sprintf("%s/api/v1/rdp-staged/%s", base, token)
|
||||||
|
remotePath := winsmb.DownloadFullPath + `\` + fileHeader.Filename
|
||||||
|
deployCmd := fmt.Sprintf(
|
||||||
|
`powershell -NoProfile -WindowStyle Hidden -Command "$d='%s';if(!(Test-Path $d)){ni -ItemType Directory -Path $d -Force|Out-Null};iwr -UseBasicParsing -Uri '%s' -OutFile (Join-Path $d '%s')"`,
|
||||||
|
strings.ReplaceAll(winsmb.DownloadFullPath, `'`, `''`),
|
||||||
|
downloadURL,
|
||||||
|
strings.ReplaceAll(fileHeader.Filename, `'`, `''`),
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusOK, model.Response{
|
||||||
|
Code: 200,
|
||||||
|
Data: gin.H{
|
||||||
|
"token": token,
|
||||||
|
"download_url": downloadURL,
|
||||||
|
"remote_path": remotePath,
|
||||||
|
"deploy_command": deployCmd,
|
||||||
|
"mode": "stage",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeStaged 供 Windows 主机拉取暂存文件(token 即密钥)// Logan.Li
|
||||||
|
func (h *RDPFileHandler) ServeStaged(c *gin.Context) {
|
||||||
|
token := c.Param("token")
|
||||||
|
item, ok := rdpfilestage.Get(token)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "链接已过期或不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", "attachment; filename=\""+item.Name+"\"")
|
||||||
|
c.Data(http.StatusOK, "application/octet-stream", item.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicHTTPBase(c *gin.Context) string {
|
||||||
|
scheme := "http"
|
||||||
|
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
host := c.Request.Host
|
||||||
|
if fwd := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); fwd != "" {
|
||||||
|
host = strings.Split(fwd, ",")[0]
|
||||||
|
}
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
rdpengine "ezops/pkg/rdp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rdpLiveBroadcaster 将 RDP 主会话帧广播给审计实时查看端,慢消费者会丢旧帧保最新。 // Logan.Li
|
||||||
|
type rdpLiveBroadcaster struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
clients map[chan rdpengine.ServerFrame]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRDPLiveBroadcaster() *rdpLiveBroadcaster {
|
||||||
|
return &rdpLiveBroadcaster{clients: make(map[chan rdpengine.ServerFrame]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *rdpLiveBroadcaster) subscribe() chan rdpengine.ServerFrame {
|
||||||
|
ch := make(chan rdpengine.ServerFrame, 8)
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.closed {
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
b.clients[ch] = struct{}{}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *rdpLiveBroadcaster) unsubscribe(ch chan rdpengine.ServerFrame) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if _, ok := b.clients[ch]; ok {
|
||||||
|
delete(b.clients, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *rdpLiveBroadcaster) publish(frame rdpengine.ServerFrame) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for ch := range b.clients {
|
||||||
|
select {
|
||||||
|
case ch <- frame:
|
||||||
|
default:
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- frame:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *rdpLiveBroadcaster) close() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.closed = true
|
||||||
|
for ch := range b.clients {
|
||||||
|
delete(b.clients, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/middleware"
|
||||||
|
"ezops/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RDPSessionHandler 管理 Windows RDP 图形会话审计记录 // Logan.Li
|
||||||
|
type RDPSessionHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
killFunc func(sessionID uint) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRDPSessionHandler(db *gorm.DB, killFunc func(sessionID uint) bool) *RDPSessionHandler {
|
||||||
|
return &RDPSessionHandler{db: db, killFunc: killFunc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPSessionHandler) ListRDPSessions(c *gin.Context) {
|
||||||
|
var req model.RDPSessionQuery
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Page <= 0 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize <= 0 {
|
||||||
|
req.PageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
query := h.db.Model(&model.RDPSession{})
|
||||||
|
if req.ServerID > 0 {
|
||||||
|
query = query.Where("server_id = ?", req.ServerID)
|
||||||
|
}
|
||||||
|
if req.Username != "" {
|
||||||
|
query = query.Where("username LIKE ?", "%"+req.Username+"%")
|
||||||
|
}
|
||||||
|
if req.Status != "" {
|
||||||
|
query = query.Where("status = ?", req.Status)
|
||||||
|
}
|
||||||
|
if kw := strings.TrimSpace(req.Keyword); kw != "" {
|
||||||
|
like := "%" + kw + "%"
|
||||||
|
query = query.Where("username LIKE ? OR server_name LIKE ? OR server_ip LIKE ? OR client_ip LIKE ?", like, like, like, like)
|
||||||
|
}
|
||||||
|
if req.StartDate != "" {
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
|
||||||
|
query = query.Where("start_time >= ?", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.EndDate != "" {
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
|
||||||
|
query = query.Where("start_time < ?", t.Add(24*time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.HasRecord == "1" || strings.EqualFold(req.HasRecord, "true") {
|
||||||
|
query = query.Where("record_size > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sessions []model.RDPSession
|
||||||
|
if err := query.Order("id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&sessions).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: sessions, Total: total}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPSessionHandler) GetRDPSession(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var session model.RDPSession
|
||||||
|
if err := h.db.First(&session, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: session})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPSessionHandler) GetRDPRecord(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var session model.RDPSession
|
||||||
|
if err := h.db.First(&session, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.RecordData == "" {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "无录制数据"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: session.RecordData})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPSessionHandler) KillRDPSession(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.killFunc != nil && h.killFunc(uint(id)) {
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
var session model.RDPSession
|
||||||
|
if err := h.db.First(&session, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
duration := int(now.Sub(session.StartTime).Seconds())
|
||||||
|
_ = h.db.Model(&session).Updates(map[string]interface{}{"status": "closed", "end_time": now, "duration": duration}).Error
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPSessionHandler) DownloadRDPRecord(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var session model.RDPSession
|
||||||
|
if err := h.db.First(&session, id).Error; err != nil || session.RecordData == "" {
|
||||||
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "无录制数据"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="rdp-session-%d.json"`, session.ID))
|
||||||
|
c.Header("Content-Length", fmt.Sprintf("%d", len(session.RecordData)))
|
||||||
|
c.String(http.StatusOK, session.RecordData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RDPSessionHandler) LogRDPReplayView(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var session model.RDPSession
|
||||||
|
if err := h.db.Select("id", "username", "server_name", "server_ip").First(&session, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := middleware.GetUserID(c)
|
||||||
|
log := model.AuditLog{
|
||||||
|
UserID: &uid,
|
||||||
|
Username: middleware.GetUsername(c),
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
UserAgent: c.GetHeader("User-Agent"),
|
||||||
|
Module: "rdp_audit",
|
||||||
|
Action: "replay_view",
|
||||||
|
Resource: "rdp_session",
|
||||||
|
ResourceID: strconv.FormatUint(id, 10),
|
||||||
|
Request: fmt.Sprintf("回放 Windows RDP 会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP),
|
||||||
|
Status: 200,
|
||||||
|
}
|
||||||
|
_ = h.db.Create(&log).Error
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ func (h *ServerHandler) ListServers(c *gin.Context) {
|
|||||||
|
|
||||||
// 填充 has_auth 字段(密码和SSH密钥在JSON中隐藏,但前端需知道是否有凭据) // Logan.Li
|
// 填充 has_auth 字段(密码和SSH密钥在JSON中隐藏,但前端需知道是否有凭据) // Logan.Li
|
||||||
for i := range servers {
|
for i := range servers {
|
||||||
|
normalizeServerRemoteAccess(&servers[i])
|
||||||
servers[i].HasAuth = servers[i].Password != "" || servers[i].SSHKey != ""
|
servers[i].HasAuth = servers[i].Password != "" || servers[i].SSHKey != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +163,9 @@ func (h *ServerHandler) GetServer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeServerRemoteAccess(&server)
|
||||||
|
server.HasAuth = server.Password != "" || server.SSHKey != ""
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Response{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "success",
|
Message: "success",
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import (
|
|||||||
|
|
||||||
// 批量操作域:批量导入 + 批量删除 // Logan.Li
|
// 批量操作域:批量导入 + 批量删除 // Logan.Li
|
||||||
|
|
||||||
// BatchCreateServers 批量导入服务器 // Logan.Li
|
// BatchCreateServers 批量导入服务器,Linux 默认 SSH 凭据,Windows 默认 RDP 凭据 // Logan.Li
|
||||||
// 接收 {servers:[...], region_id, auth_type, credential_id},逐条校验并创建,跳过重复(主机名或IP已存在)
|
|
||||||
func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
||||||
var req model.BatchCreateServerRequest
|
var req model.BatchCreateServerRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -30,24 +29,19 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
|||||||
authType = "password"
|
authType = "password"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果指定了凭据,提前解密(plain 在循环内由 applyCredential 解析填充) // Logan.Li
|
sshCredentialID := req.SSHCredentialID
|
||||||
var (
|
if sshCredentialID == nil {
|
||||||
hasCred bool
|
sshCredentialID = req.CredentialID
|
||||||
cred model.Credential
|
}
|
||||||
credPlain string
|
sshCred, sshPlain, hasSSHCred, err := h.loadImportCredential(sshCredentialID, "ssh")
|
||||||
)
|
if err != nil {
|
||||||
if req.CredentialID != nil && *req.CredentialID > 0 {
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
if err := h.db.First(&cred, *req.CredentialID).Error; err != nil {
|
return
|
||||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "凭据不存在"})
|
}
|
||||||
return
|
rdpCred, rdpPlain, hasRDPCred, err := h.loadImportCredential(req.RDPCredentialID, "rdp")
|
||||||
}
|
if err != nil {
|
||||||
p, err := h.crypt.Decrypt(cred.Content)
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
if err != nil {
|
return
|
||||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "凭据解密失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
credPlain = p
|
|
||||||
hasCred = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result := model.BatchCreateResult{Errors: []string{}}
|
result := model.BatchCreateResult{Errors: []string{}}
|
||||||
@@ -68,13 +62,30 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
osType := normalizeImportOSType(item.OSType, item.OS)
|
||||||
|
protocol := strings.TrimSpace(strings.ToLower(item.Protocol))
|
||||||
|
if protocol == "" {
|
||||||
|
if osType == "windows" {
|
||||||
|
protocol = "rdp"
|
||||||
|
} else {
|
||||||
|
protocol = "ssh"
|
||||||
|
}
|
||||||
|
}
|
||||||
port := item.Port
|
port := item.Port
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
port = 22
|
port = 22
|
||||||
}
|
}
|
||||||
|
rdpPort := item.RDPPort
|
||||||
|
if rdpPort == 0 {
|
||||||
|
rdpPort = 3389
|
||||||
|
}
|
||||||
username := strings.TrimSpace(item.Username)
|
username := strings.TrimSpace(item.Username)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = "root"
|
if protocol == "rdp" {
|
||||||
|
username = "Administrator"
|
||||||
|
} else {
|
||||||
|
username = "root"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server := model.Server{
|
server := model.Server{
|
||||||
@@ -83,6 +94,9 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
|||||||
Port: port,
|
Port: port,
|
||||||
Username: username,
|
Username: username,
|
||||||
AuthType: authType, // 无凭证时用请求 authType(默认 password)
|
AuthType: authType, // 无凭证时用请求 authType(默认 password)
|
||||||
|
OSType: osType,
|
||||||
|
Protocol: protocol,
|
||||||
|
RDPPort: rdpPort,
|
||||||
OS: item.OS,
|
OS: item.OS,
|
||||||
CPU: item.CPU,
|
CPU: item.CPU,
|
||||||
Memory: item.Memory * 1024, // GB → MB
|
Memory: item.Memory * 1024, // GB → MB
|
||||||
@@ -91,8 +105,10 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
|||||||
RegionID: req.RegionID,
|
RegionID: req.RegionID,
|
||||||
Status: "stopped",
|
Status: "stopped",
|
||||||
}
|
}
|
||||||
if hasCred {
|
if protocol == "rdp" && hasRDPCred {
|
||||||
applyCredential(cred, credPlain, &server) // 有凭证则覆盖认证字段(ssh→私钥/ssh_user/ssh_port) // Logan.Li
|
applyCredential(rdpCred, rdpPlain, &server)
|
||||||
|
} else if protocol != "rdp" && hasSSHCred {
|
||||||
|
applyCredential(sshCred, sshPlain, &server)
|
||||||
}
|
}
|
||||||
if err := h.db.Create(&server).Error; err != nil {
|
if err := h.db.Create(&server).Error; err != nil {
|
||||||
result.Failed++
|
result.Failed++
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"ezops/model"
|
||||||
|
"ezops/pkg/crypto"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *ServerHandler) loadImportCredential(id *uint, wantType string) (model.Credential, string, bool, error) {
|
||||||
|
return loadImportCredentialByDB(h.db, h.crypt, id, wantType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CloudSyncHandler) loadImportCredential(id *uint, wantType string) (model.Credential, string, bool, error) {
|
||||||
|
return loadImportCredentialByDB(h.db, h.crypt, id, wantType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportCredentialByDB(db *gorm.DB, crypt *crypto.SecretBox, id *uint, wantType string) (model.Credential, string, bool, error) {
|
||||||
|
var cred model.Credential
|
||||||
|
if id == nil || *id == 0 {
|
||||||
|
return cred, "", false, nil
|
||||||
|
}
|
||||||
|
if err := db.First(&cred, *id).Error; err != nil {
|
||||||
|
return cred, "", false, fmt.Errorf("凭据不存在")
|
||||||
|
}
|
||||||
|
if cred.SecretType != wantType {
|
||||||
|
return cred, "", false, fmt.Errorf("请选择 %s 类型凭据", strings.ToUpper(wantType))
|
||||||
|
}
|
||||||
|
plain, err := crypt.Decrypt(cred.Content)
|
||||||
|
if err != nil {
|
||||||
|
return cred, "", false, fmt.Errorf("凭据解密失败")
|
||||||
|
}
|
||||||
|
return cred, plain, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeImportOSType(osType, osName string) string {
|
||||||
|
raw := strings.ToLower(strings.TrimSpace(osType + " " + osName))
|
||||||
|
if strings.Contains(raw, "windows") || strings.Contains(raw, "win") {
|
||||||
|
return "windows"
|
||||||
|
}
|
||||||
|
return "linux"
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeServerRemoteAccess 补齐历史导入数据中缺失的 os_type/protocol 字段 // Logan.Li
|
||||||
|
func normalizeServerRemoteAccess(server *model.Server) {
|
||||||
|
if server == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if server.OSType != "" && server.Protocol != "" {
|
||||||
|
if server.OSType == "windows" && server.Protocol != "rdp" {
|
||||||
|
server.Protocol = "rdp"
|
||||||
|
}
|
||||||
|
if server.Protocol == "rdp" && server.OSType == "" {
|
||||||
|
server.OSType = "windows"
|
||||||
|
}
|
||||||
|
if server.RDPPort == 0 {
|
||||||
|
server.RDPPort = 3389
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
osType := normalizeImportOSType(server.OSType, server.OS)
|
||||||
|
if osType == "windows" {
|
||||||
|
server.OSType = "windows"
|
||||||
|
server.Protocol = "rdp"
|
||||||
|
if server.RDPPort == 0 {
|
||||||
|
server.RDPPort = 3389
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if server.OSType == "" {
|
||||||
|
server.OSType = "linux"
|
||||||
|
}
|
||||||
|
if server.Protocol == "" {
|
||||||
|
server.Protocol = "ssh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRDPServer 判断资产是否应走 RDP 图形会话 // Logan.Li
|
||||||
|
func isRDPServer(server model.Server) bool {
|
||||||
|
normalizeServerRemoteAccess(&server)
|
||||||
|
return server.Protocol == "rdp" || server.OSType == "windows"
|
||||||
|
}
|
||||||
@@ -68,8 +68,9 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
|
|||||||
// 联表带出会话录制大小,前端可判断能否回放 // Logan.Li
|
// 联表带出会话录制大小,前端可判断能否回放 // Logan.Li
|
||||||
var items []model.SessionCommandItem
|
var items []model.SessionCommandItem
|
||||||
listQuery := h.db.Table("sys_session_commands AS sc").
|
listQuery := h.db.Table("sys_session_commands AS sc").
|
||||||
Select("sc.*, COALESCE(ts.record_size, 0) AS session_record_size").
|
Select("sc.*, COALESCE(ts.record_size, rs.record_size, 0) AS session_record_size").
|
||||||
Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id")
|
Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id AND COALESCE(sc.session_type, 'terminal') = 'terminal'").
|
||||||
|
Joins("LEFT JOIN sys_rdp_sessions AS rs ON rs.id = sc.session_id AND sc.session_type = 'rdp'")
|
||||||
if req.SessionID > 0 {
|
if req.SessionID > 0 {
|
||||||
listQuery = listQuery.Where("sc.session_id = ?", req.SessionID)
|
listQuery = listQuery.Where("sc.session_id = ?", req.SessionID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func (r *asciicastRecorder) writeHeader() {
|
|||||||
"width": 80,
|
"width": 80,
|
||||||
"height": 24,
|
"height": 24,
|
||||||
"timestamp": r.startTime.Unix(),
|
"timestamp": r.startTime.Unix(),
|
||||||
|
"aiops_input_recorded": false,
|
||||||
"env": map[string]string{
|
"env": map[string]string{
|
||||||
"SHELL": "/bin/bash",
|
"SHELL": "/bin/bash",
|
||||||
"TERM": "xterm-256color",
|
"TERM": "xterm-256color",
|
||||||
@@ -396,10 +397,6 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
|||||||
case 0:
|
case 0:
|
||||||
if msg.Data != "" {
|
if msg.Data != "" {
|
||||||
stdinPipe.Write([]byte(msg.Data))
|
stdinPipe.Write([]byte(msg.Data))
|
||||||
// 录入用户输入,回放可见键入内容 // Logan.Li
|
|
||||||
if recorder.Len() < maxRecordSize {
|
|
||||||
recorder.WriteRow([]byte(msg.Data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command extraction from stdin
|
// Command extraction from stdin
|
||||||
cmdBuf.WriteString(msg.Data)
|
cmdBuf.WriteString(msg.Data)
|
||||||
@@ -469,12 +466,13 @@ func extractCommand(input string) string {
|
|||||||
func recordCommand(db *gorm.DB, sessionID, serverID uint, serverName, username, command string) uint {
|
func recordCommand(db *gorm.DB, sessionID, serverID uint, serverName, username, command string) uint {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
cmd := model.SessionCommand{
|
cmd := model.SessionCommand{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
ServerID: serverID,
|
SessionType: "terminal",
|
||||||
ServerName: serverName,
|
ServerID: serverID,
|
||||||
Username: username,
|
ServerName: serverName,
|
||||||
Command: command,
|
Username: username,
|
||||||
Timestamp: now,
|
Command: command,
|
||||||
|
Timestamp: now,
|
||||||
}
|
}
|
||||||
if err := db.Create(&cmd).Error; err != nil {
|
if err := db.Create(&cmd).Error; err != nil {
|
||||||
log.Printf("[WebShell] Command record failed: %v", err)
|
log.Printf("[WebShell] Command record failed: %v", err)
|
||||||
|
|||||||
+66
-47
@@ -8,50 +8,58 @@ import "time"
|
|||||||
|
|
||||||
// CreateServerRequest 创建服务器
|
// CreateServerRequest 创建服务器
|
||||||
type CreateServerRequest struct {
|
type CreateServerRequest struct {
|
||||||
Hostname string `json:"hostname" binding:"required"`
|
Hostname string `json:"hostname" binding:"required"`
|
||||||
IP string `json:"ip" binding:"required"`
|
IP string `json:"ip" binding:"required"`
|
||||||
PublicIP string `json:"public_ip"`
|
PublicIP string `json:"public_ip"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
SSHKey string `json:"ssh_key"`
|
SSHKey string `json:"ssh_key"`
|
||||||
AuthType string `json:"auth_type"`
|
AuthType string `json:"auth_type"`
|
||||||
OS string `json:"os"`
|
CredentialID *uint `json:"credential_id"`
|
||||||
OSVersion string `json:"os_version"`
|
OSType string `json:"os_type"`
|
||||||
Arch string `json:"arch"`
|
Protocol string `json:"protocol"`
|
||||||
CPU int `json:"cpu"`
|
RDPPort int `json:"rdp_port"`
|
||||||
Memory int64 `json:"memory"`
|
OS string `json:"os"`
|
||||||
Disk int64 `json:"disk"`
|
OSVersion string `json:"os_version"`
|
||||||
RegionID *uint `json:"region_id"`
|
Arch string `json:"arch"`
|
||||||
CloudID *uint `json:"cloud_id"`
|
CPU int `json:"cpu"`
|
||||||
Tags string `json:"tags"`
|
Memory int64 `json:"memory"`
|
||||||
Description string `json:"description"`
|
Disk int64 `json:"disk"`
|
||||||
|
RegionID *uint `json:"region_id"`
|
||||||
|
CloudID *uint `json:"cloud_id"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateServerRequest 更新服务器 — 部分更新 (使用 map)
|
// UpdateServerRequest 更新服务器 — 部分更新 (使用 map)
|
||||||
type UpdateServerRequest struct {
|
type UpdateServerRequest struct {
|
||||||
Hostname *string `json:"hostname"`
|
Hostname *string `json:"hostname"`
|
||||||
IP *string `json:"ip"`
|
IP *string `json:"ip"`
|
||||||
PublicIP *string `json:"public_ip"`
|
PublicIP *string `json:"public_ip"`
|
||||||
Port *int `json:"port"`
|
Port *int `json:"port"`
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
SSHKey *string `json:"ssh_key"`
|
SSHKey *string `json:"ssh_key"`
|
||||||
AuthType *string `json:"auth_type"`
|
AuthType *string `json:"auth_type"`
|
||||||
OS *string `json:"os"`
|
CredentialID *uint `json:"credential_id"`
|
||||||
OSVersion *string `json:"os_version"`
|
OSType *string `json:"os_type"`
|
||||||
Arch *string `json:"arch"`
|
Protocol *string `json:"protocol"`
|
||||||
CPU *int `json:"cpu"`
|
RDPPort *int `json:"rdp_port"`
|
||||||
Memory *int64 `json:"memory"`
|
OS *string `json:"os"`
|
||||||
Disk *int64 `json:"disk"`
|
OSVersion *string `json:"os_version"`
|
||||||
Status *string `json:"status"`
|
Arch *string `json:"arch"`
|
||||||
CPUUsage *float64 `json:"cpu_usage"`
|
CPU *int `json:"cpu"`
|
||||||
MemoryUsage *float64 `json:"memory_usage"`
|
Memory *int64 `json:"memory"`
|
||||||
DiskUsage *float64 `json:"disk_usage"`
|
Disk *int64 `json:"disk"`
|
||||||
RegionID *uint `json:"region_id"`
|
Status *string `json:"status"`
|
||||||
CloudID *uint `json:"cloud_id"`
|
CPUUsage *float64 `json:"cpu_usage"`
|
||||||
Tags *string `json:"tags"`
|
MemoryUsage *float64 `json:"memory_usage"`
|
||||||
Description *string `json:"description"`
|
DiskUsage *float64 `json:"disk_usage"`
|
||||||
|
RegionID *uint `json:"region_id"`
|
||||||
|
CloudID *uint `json:"cloud_id"`
|
||||||
|
Tags *string `json:"tags"`
|
||||||
|
Description *string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerListItem 服务器列表项 (精简)
|
// ServerListItem 服务器列表项 (精简)
|
||||||
@@ -85,6 +93,12 @@ type CreateCredentialRequest struct {
|
|||||||
SSHPort int `json:"ssh_port"`
|
SSHPort int `json:"ssh_port"`
|
||||||
SSHPassword string `json:"ssh_password"`
|
SSHPassword string `json:"ssh_password"`
|
||||||
|
|
||||||
|
// RDP
|
||||||
|
RDPUsername string `json:"rdp_username"`
|
||||||
|
RDPPassword string `json:"rdp_password"`
|
||||||
|
RDPDomain string `json:"rdp_domain"`
|
||||||
|
RDPSecurity string `json:"rdp_security"`
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
DockerServer string `json:"docker_server"`
|
DockerServer string `json:"docker_server"`
|
||||||
DockerUsername string `json:"docker_username"`
|
DockerUsername string `json:"docker_username"`
|
||||||
@@ -113,6 +127,11 @@ type UpdateCredentialRequest struct {
|
|||||||
SSHPort *int `json:"ssh_port"`
|
SSHPort *int `json:"ssh_port"`
|
||||||
SSHPassword *string `json:"ssh_password"`
|
SSHPassword *string `json:"ssh_password"`
|
||||||
|
|
||||||
|
RDPUsername *string `json:"rdp_username"`
|
||||||
|
RDPPassword *string `json:"rdp_password"`
|
||||||
|
RDPDomain *string `json:"rdp_domain"`
|
||||||
|
RDPSecurity *string `json:"rdp_security"`
|
||||||
|
|
||||||
DockerServer *string `json:"docker_server"`
|
DockerServer *string `json:"docker_server"`
|
||||||
DockerUsername *string `json:"docker_username"`
|
DockerUsername *string `json:"docker_username"`
|
||||||
DockerPassword *string `json:"docker_password"`
|
DockerPassword *string `json:"docker_password"`
|
||||||
@@ -161,14 +180,14 @@ type UpdateAlertContactGroupRequest struct {
|
|||||||
|
|
||||||
// CreateAlertRuleRequest 创建告警规则
|
// CreateAlertRuleRequest 创建告警规则
|
||||||
type CreateAlertRuleRequest struct {
|
type CreateAlertRuleRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Metric string `json:"metric" binding:"required"`
|
Metric string `json:"metric" binding:"required"`
|
||||||
Condition string `json:"condition" binding:"required"` // >, <, >=, <=, ==
|
Condition string `json:"condition" binding:"required"` // >, <, >=, <=, ==
|
||||||
Threshold float64 `json:"threshold" binding:"required"`
|
Threshold float64 `json:"threshold" binding:"required"`
|
||||||
Duration string `json:"duration"` // 30s, 1m, 5m
|
Duration string `json:"duration"` // 30s, 1m, 5m
|
||||||
ContactIDs []uint `json:"contact_ids"`
|
ContactIDs []uint `json:"contact_ids"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAlertRuleRequest 更新告警规则
|
// UpdateAlertRuleRequest 更新告警规则
|
||||||
|
|||||||
+6
-4
@@ -1,19 +1,23 @@
|
|||||||
module ezops
|
module ezops
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/go-ldap/ldap/v3 v3.4.10
|
github.com/go-ldap/ldap/v3 v3.4.10
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/gosnmp/gosnmp v1.35.0
|
github.com/gosnmp/gosnmp v1.35.0
|
||||||
|
github.com/hirochachacha/go-smb2 v1.1.0
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.47
|
||||||
github.com/nalgeon/redka v0.2.0
|
github.com/nalgeon/redka v0.2.0
|
||||||
github.com/pkg/sftp v1.13.10
|
github.com/pkg/sftp v1.13.10
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/spf13/viper v1.18.2
|
||||||
go.uber.org/zap v1.26.0
|
go.uber.org/zap v1.26.0
|
||||||
golang.org/x/crypto v0.46.0
|
golang.org/x/crypto v0.46.0
|
||||||
@@ -32,13 +36,13 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
|
github.com/geoffgarside/ber v1.1.0 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.16.0 // indirect
|
github.com/go-playground/validator/v10 v10.16.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
@@ -52,13 +56,11 @@ require (
|
|||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.47 // indirect
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
|
||||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
|||||||
+7
-2
@@ -30,6 +30,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
|
|||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
|
github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
|
||||||
|
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
@@ -76,6 +78,8 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
|
|||||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
|
||||||
|
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
@@ -115,8 +119,6 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
|
|||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
@@ -188,6 +190,7 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
|
|||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
@@ -203,6 +206,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
@@ -224,6 +228,7 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"ezops/seed"
|
"ezops/seed"
|
||||||
"ezops/pkg/appdir"
|
"ezops/pkg/appdir"
|
||||||
"ezops/pkg/auditcleaner"
|
"ezops/pkg/auditcleaner"
|
||||||
|
"ezops/pkg/billingsync"
|
||||||
"ezops/pkg/database"
|
"ezops/pkg/database"
|
||||||
snmppkg "ezops/pkg/snmp"
|
snmppkg "ezops/pkg/snmp"
|
||||||
"ezops/pkg/cronscheduler"
|
"ezops/pkg/cronscheduler"
|
||||||
@@ -304,6 +305,42 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// 每日 10:30 同步云账号昨日账单(阿里云 T+1 出账) // Logan.Li
|
||||||
|
go func() {
|
||||||
|
runBillingSync := func() {
|
||||||
|
start := time.Now()
|
||||||
|
result := billingsync.SyncDate(db, billingsync.Yesterday(), nil)
|
||||||
|
msg := billingsync.SummaryMessage(result)
|
||||||
|
status := "success"
|
||||||
|
if result.Accounts == 0 {
|
||||||
|
status = "failed"
|
||||||
|
msg = "没有可同步的活跃云账号"
|
||||||
|
} else if result.Created == 0 && result.Updated == 0 && len(result.Errors) > 0 {
|
||||||
|
status = "failed"
|
||||||
|
msg = result.Errors[0]
|
||||||
|
} else if len(result.Errors) > 0 {
|
||||||
|
msg += ";" + result.Errors[0]
|
||||||
|
}
|
||||||
|
systask.Record("cloud_billing_sync", status, msg, time.Since(start))
|
||||||
|
if status == "success" && (result.Created > 0 || result.Updated > 0) {
|
||||||
|
log.Info("云账单定时同步完成",
|
||||||
|
zap.String("date", result.BillingDate),
|
||||||
|
zap.Int("created", result.Created),
|
||||||
|
zap.Int("updated", result.Updated),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
now := time.Now()
|
||||||
|
next := time.Date(now.Year(), now.Month(), now.Day(), 10, 30, 0, 0, now.Location())
|
||||||
|
if !now.Before(next) {
|
||||||
|
next = next.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Until(next))
|
||||||
|
runBillingSync()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 前端静态文件服务(go:embed 模式,二进制部署用) // Logan.Li
|
// 前端静态文件服务(go:embed 模式,二进制部署用) // Logan.Li
|
||||||
setupFrontend(r)
|
setupFrontend(r)
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import "time"
|
|||||||
type AgentCLISession struct {
|
type AgentCLISession struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
UserID uint `gorm:"index" json:"user_id"` // 发起用户
|
UserID uint `gorm:"index" json:"user_id"` // 发起用户
|
||||||
ServerID uint `gorm:"index" json:"server_id"` // 目标主机
|
Mode string `gorm:"size:16;default:host;index" json:"mode"` // host | kubernetes
|
||||||
|
ServerID uint `gorm:"index" json:"server_id"` // 主机模式目标主机
|
||||||
ServerName string `gorm:"size:128" json:"server_name"` // 主机名快照
|
ServerName string `gorm:"size:128" json:"server_name"` // 主机名快照
|
||||||
ServerHost string `gorm:"size:64" json:"server_host"` // 主机 IP 快照
|
ServerHost string `gorm:"size:64" json:"server_host"` // 主机 IP 快照
|
||||||
|
ClusterID uint `gorm:"index" json:"cluster_id"` // K8s 模式集群 ID
|
||||||
|
ClusterName string `gorm:"size:128" json:"cluster_name"` // 集群名快照
|
||||||
|
Namespace string `gorm:"size:128" json:"namespace"` // K8s 命名空间
|
||||||
|
PodName string `gorm:"size:128" json:"pod_name"` // 可选目标 Pod
|
||||||
|
Container string `gorm:"size:128" json:"container"` // 可选目标容器
|
||||||
Title string `gorm:"size:255" json:"title"` // 任务标题(query 截断)
|
Title string `gorm:"size:255" json:"title"` // 任务标题(query 截断)
|
||||||
Status string `gorm:"size:32;default:running" json:"status"` // running/done/aborted/error
|
Status string `gorm:"size:32;default:running" json:"status"` // running/done/aborted/error
|
||||||
Provider string `gorm:"size:50" json:"provider"` // LLM 提供商
|
Provider string `gorm:"size:50" json:"provider"` // LLM 提供商
|
||||||
|
|||||||
+72
-53
@@ -9,27 +9,29 @@ import (
|
|||||||
// 核心加密字段 content 存储类型特有数据(JSON),API 不返回
|
// 核心加密字段 content 存储类型特有数据(JSON),API 不返回
|
||||||
//
|
//
|
||||||
// 类型与内容 JSON 映射:
|
// 类型与内容 JSON 映射:
|
||||||
// ssh → {"private_key":"...","public_key":"...","ssh_user":"...","ssh_port":22}
|
//
|
||||||
// docker → {"server":"...","username":"...","password":"..."}
|
// ssh → {"private_key":"...","public_key":"...","ssh_user":"...","ssh_port":22}
|
||||||
// tls → {"cert":"...","key":"...","ca":"..."}
|
// rdp → {"username":"...","password":"...","domain":"...","security":"nla"}
|
||||||
// aksk → {"provider":"...","access_key":"...","secret_key":"...","region":"..."}
|
// docker → {"server":"...","username":"...","password":"..."}
|
||||||
// database → {"db_type":"...","host":"...","port":3306,"db_name":"...","username":"...","password":"..."}
|
// tls → {"cert":"...","key":"...","ca":"..."}
|
||||||
// api → {"key":"...","endpoint":"..."}
|
// aksk → {"provider":"...","access_key":"...","secret_key":"...","region":"..."}
|
||||||
// generic → {"key":"...","value":"..."}
|
// database → {"db_type":"...","host":"...","port":3306,"db_name":"...","username":"...","password":"..."}
|
||||||
|
// api → {"key":"...","endpoint":"..."}
|
||||||
|
// generic → {"key":"...","value":"..."}
|
||||||
type Credential struct {
|
type Credential struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Name string `gorm:"size:128;not null" json:"name"` // 凭据名称
|
Name string `gorm:"size:128;not null" json:"name"` // 凭据名称
|
||||||
VariableName string `gorm:"size:64;index" json:"variable_name,omitempty"` // 环境变量别名 e.g. MYSQL_PROD
|
VariableName string `gorm:"size:64;index" json:"variable_name,omitempty"` // 环境变量别名 e.g. MYSQL_PROD
|
||||||
SecretType string `gorm:"size:32;not null;index" json:"secret_type"` // ssh/docker/tls/aksk/database/api/generic
|
SecretType string `gorm:"size:32;not null;index" json:"secret_type"` // ssh/rdp/docker/tls/aksk/database/api/generic
|
||||||
Summary string `gorm:"size:255" json:"summary,omitempty"` // 关键信息摘要(脱敏展示)
|
Summary string `gorm:"size:255" json:"summary,omitempty"` // 关键信息摘要(脱敏展示)
|
||||||
Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回
|
Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回
|
||||||
Tags string `gorm:"type:text" json:"tags,omitempty"` // JSON 数组: ["生产环境","阿里云"]
|
Tags string `gorm:"type:text" json:"tags,omitempty"` // JSON 数组: ["生产环境","阿里云"]
|
||||||
Description string `gorm:"size:500" json:"description,omitempty"`
|
Description string `gorm:"size:500" json:"description,omitempty"`
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 过期时间
|
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 过期时间
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"` // 启用/禁用
|
IsActive bool `gorm:"default:true" json:"is_active"` // 启用/禁用
|
||||||
EnvPrefix string `gorm:"size:32" json:"env_prefix,omitempty"` // 引用 ID(可复制)
|
EnvPrefix string `gorm:"size:32" json:"env_prefix,omitempty"` // 引用 ID(可复制)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Credential) TableName() string {
|
func (Credential) TableName() string {
|
||||||
@@ -47,6 +49,14 @@ type SSHSecretContent struct {
|
|||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RDPSecretContent RDP 凭据 content,后端渲染模式下仅服务端解密使用 // Logan.Li
|
||||||
|
type RDPSecretContent struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
Security string `json:"security,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// DockerSecretContent Docker 凭据 content
|
// DockerSecretContent Docker 凭据 content
|
||||||
type DockerSecretContent struct {
|
type DockerSecretContent struct {
|
||||||
Server string `json:"server"`
|
Server string `json:"server"`
|
||||||
@@ -95,13 +105,13 @@ type GenericSecretContent struct {
|
|||||||
|
|
||||||
// CreateSecretRequest 创建凭据请求
|
// CreateSecretRequest 创建凭据请求
|
||||||
type CreateSecretRequest struct {
|
type CreateSecretRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
VariableName string `json:"variable_name"`
|
VariableName string `json:"variable_name"`
|
||||||
SecretType string `json:"secret_type" binding:"required,oneof=ssh docker tls aksk database api generic"`
|
SecretType string `json:"secret_type" binding:"required,oneof=ssh rdp docker tls aksk database api generic"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ExpiresAt *time.Time `json:"expires_at"`
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
|
|
||||||
// 类型特有字段(明文,后端组装成 JSON 加密存储)
|
// 类型特有字段(明文,后端组装成 JSON 加密存储)
|
||||||
// SSH
|
// SSH
|
||||||
@@ -110,6 +120,11 @@ type CreateSecretRequest struct {
|
|||||||
SSHUser string `json:"ssh_user,omitempty"`
|
SSHUser string `json:"ssh_user,omitempty"`
|
||||||
SSHPort int `json:"ssh_port,omitempty"`
|
SSHPort int `json:"ssh_port,omitempty"`
|
||||||
SSHPassword string `json:"ssh_password,omitempty"`
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
// RDP
|
||||||
|
RDPUsername string `json:"rdp_username,omitempty"`
|
||||||
|
RDPPassword string `json:"rdp_password,omitempty"`
|
||||||
|
RDPDomain string `json:"rdp_domain,omitempty"`
|
||||||
|
RDPSecurity string `json:"rdp_security,omitempty"`
|
||||||
// Docker
|
// Docker
|
||||||
DockerServer string `json:"docker_server,omitempty"`
|
DockerServer string `json:"docker_server,omitempty"`
|
||||||
DockerUsername string `json:"docker_username,omitempty"`
|
DockerUsername string `json:"docker_username,omitempty"`
|
||||||
@@ -140,39 +155,43 @@ type CreateSecretRequest struct {
|
|||||||
|
|
||||||
// UpdateSecretRequest 更新凭据请求
|
// UpdateSecretRequest 更新凭据请求
|
||||||
type UpdateSecretRequest struct {
|
type UpdateSecretRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
VariableName *string `json:"variable_name"`
|
VariableName *string `json:"variable_name"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
ExpiresAt *time.Time `json:"expires_at"`
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
|
|
||||||
// 类型特有字段 — 传了才更新
|
// 类型特有字段 — 传了才更新
|
||||||
SSHPrivateKey *string `json:"ssh_private_key,omitempty"`
|
SSHPrivateKey *string `json:"ssh_private_key,omitempty"`
|
||||||
SSHPublicKey *string `json:"ssh_public_key,omitempty"`
|
SSHPublicKey *string `json:"ssh_public_key,omitempty"`
|
||||||
SSHUser *string `json:"ssh_user,omitempty"`
|
SSHUser *string `json:"ssh_user,omitempty"`
|
||||||
SSHPort *int `json:"ssh_port,omitempty"`
|
SSHPort *int `json:"ssh_port,omitempty"`
|
||||||
SSHPassword *string `json:"ssh_password,omitempty"`
|
SSHPassword *string `json:"ssh_password,omitempty"`
|
||||||
|
RDPUsername *string `json:"rdp_username,omitempty"`
|
||||||
|
RDPPassword *string `json:"rdp_password,omitempty"`
|
||||||
|
RDPDomain *string `json:"rdp_domain,omitempty"`
|
||||||
|
RDPSecurity *string `json:"rdp_security,omitempty"`
|
||||||
DockerServer *string `json:"docker_server,omitempty"`
|
DockerServer *string `json:"docker_server,omitempty"`
|
||||||
DockerUsername *string `json:"docker_username,omitempty"`
|
DockerUsername *string `json:"docker_username,omitempty"`
|
||||||
DockerPassword *string `json:"docker_password,omitempty"`
|
DockerPassword *string `json:"docker_password,omitempty"`
|
||||||
TLSCert *string `json:"tls_cert,omitempty"`
|
TLSCert *string `json:"tls_cert,omitempty"`
|
||||||
TLSKey *string `json:"tls_key,omitempty"`
|
TLSKey *string `json:"tls_key,omitempty"`
|
||||||
TLSCA *string `json:"tls_ca,omitempty"`
|
TLSCA *string `json:"tls_ca,omitempty"`
|
||||||
AKSKProvider *string `json:"aksk_provider,omitempty"`
|
AKSKProvider *string `json:"aksk_provider,omitempty"`
|
||||||
AKSKAccessKey *string `json:"aksk_access_key,omitempty"`
|
AKSKAccessKey *string `json:"aksk_access_key,omitempty"`
|
||||||
AKSKSecretKey *string `json:"aksk_secret_key,omitempty"`
|
AKSKSecretKey *string `json:"aksk_secret_key,omitempty"`
|
||||||
AKSKRegion *string `json:"aksk_region,omitempty"`
|
AKSKRegion *string `json:"aksk_region,omitempty"`
|
||||||
DBType *string `json:"db_type,omitempty"`
|
DBType *string `json:"db_type,omitempty"`
|
||||||
DBHost *string `json:"db_host,omitempty"`
|
DBHost *string `json:"db_host,omitempty"`
|
||||||
DBPort *int `json:"db_port,omitempty"`
|
DBPort *int `json:"db_port,omitempty"`
|
||||||
DBName *string `json:"db_name,omitempty"`
|
DBName *string `json:"db_name,omitempty"`
|
||||||
DBUsername *string `json:"db_username,omitempty"`
|
DBUsername *string `json:"db_username,omitempty"`
|
||||||
DBPassword *string `json:"db_password,omitempty"`
|
DBPassword *string `json:"db_password,omitempty"`
|
||||||
APIKey *string `json:"api_key,omitempty"`
|
APIKey *string `json:"api_key,omitempty"`
|
||||||
APIEndpoint *string `json:"api_endpoint,omitempty"`
|
APIEndpoint *string `json:"api_endpoint,omitempty"`
|
||||||
GenericKey *string `json:"generic_key,omitempty"`
|
GenericKey *string `json:"generic_key,omitempty"`
|
||||||
GenericValue *string `json:"generic_value,omitempty"`
|
GenericValue *string `json:"generic_value,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretListResponse 列表返回(内容脱敏)
|
// SecretListResponse 列表返回(内容脱敏)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// RDPSession RDP 图形会话记录,独立于 SSH 终端审计表便于后续录屏扩展 // Logan.Li
|
||||||
|
type RDPSession struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID *uint `json:"user_id"`
|
||||||
|
Username string `gorm:"size:64" json:"username"`
|
||||||
|
ServerID uint `json:"server_id"`
|
||||||
|
ServerName string `gorm:"size:128" json:"server_name"`
|
||||||
|
ServerIP string `gorm:"size:64" json:"server_ip"`
|
||||||
|
ServerPort int `json:"server_port"`
|
||||||
|
ClientIP string `gorm:"size:64" json:"client_ip"`
|
||||||
|
Width int `gorm:"default:1280" json:"width"`
|
||||||
|
Height int `gorm:"default:800" json:"height"`
|
||||||
|
Status string `gorm:"size:32" json:"status"` // active, closed, failed // Logan.Li
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Error string `gorm:"size:500" json:"error,omitempty"`
|
||||||
|
RecordData string `gorm:"type:mediumtext" json:"-"` // 图形帧录制 JSON,回放接口单独返回 // Logan.Li
|
||||||
|
RecordSize int `json:"record_size"` // 录制字节数 // Logan.Li
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime *time.Time `json:"end_time"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RDPSession) TableName() string {
|
||||||
|
return "sys_rdp_sessions"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RDPSessionQuery Windows RDP 图形会话查询 // Logan.Li
|
||||||
|
type RDPSessionQuery struct {
|
||||||
|
ServerID uint `form:"server_id"`
|
||||||
|
Username string `form:"username"`
|
||||||
|
Status string `form:"status"`
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
StartDate string `form:"start_date"`
|
||||||
|
EndDate string `form:"end_date"`
|
||||||
|
HasRecord string `form:"has_record"`
|
||||||
|
Page int `form:"page,default=1"`
|
||||||
|
PageSize int `form:"page_size,default=20"`
|
||||||
|
}
|
||||||
+132
-119
@@ -2,42 +2,44 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server 服务器模型
|
// Server 服务器模型
|
||||||
type Server struct {
|
type Server struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Hostname string `gorm:"size:128;not null" json:"hostname"`
|
Hostname string `gorm:"size:128;not null" json:"hostname"`
|
||||||
IP string `gorm:"size:64;not null" json:"ip"`
|
IP string `gorm:"size:64;not null" json:"ip"`
|
||||||
PublicIP string `gorm:"size:64" json:"public_ip"`
|
PublicIP string `gorm:"size:64" json:"public_ip"`
|
||||||
Port int `gorm:"default:22" json:"port"`
|
Port int `gorm:"default:22" json:"port"`
|
||||||
Username string `gorm:"size:64;default:root" json:"username"`
|
Username string `gorm:"size:64;default:root" json:"username"`
|
||||||
Password string `gorm:"size:255" json:"-"`
|
Password string `gorm:"size:255" json:"-"`
|
||||||
SSHKey string `gorm:"type:text" json:"-"`
|
SSHKey string `gorm:"type:text" json:"-"`
|
||||||
AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key
|
AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key
|
||||||
OS string `gorm:"size:64" json:"os"`
|
OSType string `gorm:"size:32;default:linux" json:"os_type"` // 操作系统类型:linux/windows // Logan.Li
|
||||||
Kernel string `gorm:"size:64" json:"kernel"`
|
Protocol string `gorm:"size:32;default:ssh" json:"protocol"` // 远程协议:ssh/rdp // Logan.Li
|
||||||
OSVersion string `gorm:"size:64" json:"os_version"`
|
RDPPort int `gorm:"default:3389" json:"rdp_port"` // Windows RDP 端口 // Logan.Li
|
||||||
Arch string `gorm:"size:32" json:"arch"`
|
OS string `gorm:"size:64" json:"os"`
|
||||||
CPU int `json:"cpu"`
|
Kernel string `gorm:"size:64" json:"kernel"`
|
||||||
Memory int64 `json:"memory"` // MB
|
OSVersion string `gorm:"size:64" json:"os_version"`
|
||||||
Disk int64 `json:"disk"` // GB
|
Arch string `gorm:"size:32" json:"arch"`
|
||||||
Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error
|
CPU int `json:"cpu"`
|
||||||
CPUUsage float64 `json:"cpu_usage"`
|
Memory int64 `json:"memory"` // MB
|
||||||
MemoryUsage float64 `json:"memory_usage"`
|
Disk int64 `json:"disk"` // GB
|
||||||
DiskUsage float64 `json:"disk_usage"`
|
Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error
|
||||||
Uptime int64 `json:"uptime"` // seconds
|
CPUUsage float64 `json:"cpu_usage"`
|
||||||
AgentVersion string `gorm:"size:32" json:"agent_version"`
|
MemoryUsage float64 `json:"memory_usage"`
|
||||||
AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline
|
DiskUsage float64 `json:"disk_usage"`
|
||||||
HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li
|
Uptime int64 `json:"uptime"` // seconds
|
||||||
RegionID *uint `json:"region_id"`
|
AgentVersion string `gorm:"size:32" json:"agent_version"`
|
||||||
CloudID *uint `json:"cloud_id"`
|
AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline
|
||||||
Tags string `gorm:"type:text" json:"tags"` // JSON
|
HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li
|
||||||
Description string `gorm:"size:500" json:"description"`
|
RegionID *uint `json:"region_id"`
|
||||||
LastCheckAt *time.Time `json:"last_check_at"`
|
CloudID *uint `json:"cloud_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Tags string `gorm:"type:text" json:"tags"` // JSON
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
Description string `gorm:"size:500" json:"description"`
|
||||||
|
LastCheckAt *time.Time `json:"last_check_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Region *Region `json:"region,omitempty"`
|
Region *Region `json:"region,omitempty"`
|
||||||
@@ -51,15 +53,15 @@ func (Server) TableName() string {
|
|||||||
|
|
||||||
// Cloud 云平台模型
|
// Cloud 云平台模型
|
||||||
type Cloud struct {
|
type Cloud struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Name string `gorm:"size:64;not null" json:"name"`
|
Name string `gorm:"size:64;not null" json:"name"`
|
||||||
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
|
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
|
||||||
Type string `gorm:"size:32;default:public" json:"type"` // public, private, hybrid
|
Type string `gorm:"size:32;default:public" json:"type"` // public, private, hybrid
|
||||||
Status string `gorm:"size:32;default:active" json:"status"`
|
Status string `gorm:"size:32;default:active" json:"status"`
|
||||||
Website string `gorm:"size:255" json:"website"`
|
Website string `gorm:"size:255" json:"website"`
|
||||||
Description string `gorm:"size:500" json:"description"`
|
Description string `gorm:"size:500" json:"description"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
@@ -69,18 +71,18 @@ func (Cloud) TableName() string {
|
|||||||
|
|
||||||
// CloudAccount 云账号模型
|
// CloudAccount 云账号模型
|
||||||
type CloudAccount struct {
|
type CloudAccount struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Name string `gorm:"size:128;not null" json:"name"`
|
Name string `gorm:"size:128;not null" json:"name"`
|
||||||
Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure
|
Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure
|
||||||
AccessKey string `gorm:"size:255;not null" json:"-"`
|
AccessKey string `gorm:"size:255;not null" json:"-"`
|
||||||
SecretKey string `gorm:"size:255;not null" json:"-"`
|
SecretKey string `gorm:"size:255;not null" json:"-"`
|
||||||
Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error
|
Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error
|
||||||
RegionCount int `gorm:"default:0" json:"region_count"`
|
RegionCount int `gorm:"default:0" json:"region_count"`
|
||||||
ServerCount int `gorm:"default:0" json:"server_count"`
|
ServerCount int `gorm:"default:0" json:"server_count"`
|
||||||
MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计
|
MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
@@ -90,16 +92,16 @@ func (CloudAccount) TableName() string {
|
|||||||
|
|
||||||
// Region 区域模型
|
// Region 区域模型
|
||||||
type Region struct {
|
type Region struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Name string `gorm:"size:64;not null" json:"name"`
|
Name string `gorm:"size:64;not null" json:"name"`
|
||||||
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
|
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
|
||||||
Provider string `gorm:"size:64;not null" json:"provider"`
|
Provider string `gorm:"size:64;not null" json:"provider"`
|
||||||
Location string `gorm:"size:128" json:"location"`
|
Location string `gorm:"size:128" json:"location"`
|
||||||
Status string `gorm:"size:32;default:active" json:"status"`
|
Status string `gorm:"size:32;default:active" json:"status"`
|
||||||
Endpoint string `gorm:"size:255" json:"endpoint"`
|
Endpoint string `gorm:"size:255" json:"endpoint"`
|
||||||
VPCCount int `gorm:"default:0" json:"vpc_count"`
|
VPCCount int `gorm:"default:0" json:"vpc_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
@@ -109,17 +111,17 @@ func (Region) TableName() string {
|
|||||||
|
|
||||||
// Domain 域名模型
|
// Domain 域名模型
|
||||||
type Domain struct {
|
type Domain struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Name string `gorm:"size:255;uniqueIndex;not null" json:"name"`
|
Name string `gorm:"size:255;uniqueIndex;not null" json:"name"`
|
||||||
Registrar string `gorm:"size:128" json:"registrar"`
|
Registrar string `gorm:"size:128" json:"registrar"`
|
||||||
Status string `gorm:"size:32;default:active" json:"status"` // active, expired, pending
|
Status string `gorm:"size:32;default:active" json:"status"` // active, expired, pending
|
||||||
ExpireDate *time.Time `json:"expire_date"`
|
ExpireDate *time.Time `json:"expire_date"`
|
||||||
DNSProvider string `gorm:"size:128" json:"dns_provider"`
|
DNSProvider string `gorm:"size:128" json:"dns_provider"`
|
||||||
RecordCount int `gorm:"default:0" json:"record_count"`
|
RecordCount int `gorm:"default:0" json:"record_count"`
|
||||||
SSLEnabled bool `gorm:"default:false" json:"ssl_enabled"`
|
SSLEnabled bool `gorm:"default:false" json:"ssl_enabled"`
|
||||||
SSLExpireDate *time.Time `json:"ssl_expire_date"`
|
SSLExpireDate *time.Time `json:"ssl_expire_date"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
@@ -129,46 +131,52 @@ func (Domain) TableName() string {
|
|||||||
|
|
||||||
// CreateServerRequest 创建服务器请求
|
// CreateServerRequest 创建服务器请求
|
||||||
type CreateServerRequest struct {
|
type CreateServerRequest struct {
|
||||||
Hostname string `json:"hostname" binding:"required"`
|
Hostname string `json:"hostname" binding:"required"`
|
||||||
IP string `json:"ip" binding:"required,ip"`
|
IP string `json:"ip" binding:"required,ip"`
|
||||||
PublicIP string `json:"public_ip" binding:"omitempty,ip"`
|
PublicIP string `json:"public_ip" binding:"omitempty,ip"`
|
||||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
SSHKey string `json:"ssh_key"`
|
SSHKey string `json:"ssh_key"`
|
||||||
AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"`
|
AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"`
|
||||||
CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段
|
CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段
|
||||||
OS string `json:"os"`
|
OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li
|
||||||
CPU int `json:"cpu"`
|
Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li
|
||||||
Memory int64 `json:"memory"`
|
RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li
|
||||||
Disk int64 `json:"disk"`
|
OS string `json:"os"`
|
||||||
Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
|
CPU int `json:"cpu"`
|
||||||
RegionID *uint `json:"region_id"`
|
Memory int64 `json:"memory"`
|
||||||
CloudID *uint `json:"cloud_id"`
|
Disk int64 `json:"disk"`
|
||||||
Tags string `json:"tags"`
|
Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
|
||||||
Description string `json:"description"`
|
RegionID *uint `json:"region_id"`
|
||||||
|
CloudID *uint `json:"cloud_id"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateServerRequest 更新服务器请求
|
// UpdateServerRequest 更新服务器请求
|
||||||
type UpdateServerRequest struct {
|
type UpdateServerRequest struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
IP string `json:"ip" binding:"omitempty,ip"`
|
IP string `json:"ip" binding:"omitempty,ip"`
|
||||||
PublicIP string `json:"public_ip" binding:"omitempty,ip"`
|
PublicIP string `json:"public_ip" binding:"omitempty,ip"`
|
||||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
SSHKey string `json:"ssh_key"`
|
SSHKey string `json:"ssh_key"`
|
||||||
AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"`
|
AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"`
|
||||||
CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段
|
CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段
|
||||||
OS string `json:"os"`
|
OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li
|
||||||
CPU int `json:"cpu"`
|
Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li
|
||||||
Memory int64 `json:"memory"`
|
RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li
|
||||||
Disk int64 `json:"disk"`
|
OS string `json:"os"`
|
||||||
Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
|
CPU int `json:"cpu"`
|
||||||
RegionID *uint `json:"region_id"`
|
Memory int64 `json:"memory"`
|
||||||
CloudID *uint `json:"cloud_id"`
|
Disk int64 `json:"disk"`
|
||||||
Tags string `json:"tags"`
|
Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
|
||||||
Description string `json:"description"`
|
RegionID *uint `json:"region_id"`
|
||||||
|
CloudID *uint `json:"cloud_id"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchServerItem 批量导入单条服务器 // Logan.Li
|
// BatchServerItem 批量导入单条服务器 // Logan.Li
|
||||||
@@ -177,6 +185,9 @@ type BatchServerItem struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
OSType string `json:"os_type"` // linux/windows // Logan.Li
|
||||||
|
Protocol string `json:"protocol"` // ssh/rdp // Logan.Li
|
||||||
|
RDPPort int `json:"rdp_port"` // Windows RDP 端口 // Logan.Li
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
CPU int `json:"cpu"`
|
CPU int `json:"cpu"`
|
||||||
Memory int64 `json:"memory"` // GB(后端转 MB 存储)
|
Memory int64 `json:"memory"` // GB(后端转 MB 存储)
|
||||||
@@ -186,10 +197,12 @@ type BatchServerItem struct {
|
|||||||
|
|
||||||
// BatchCreateServerRequest 批量导入服务器请求 // Logan.Li
|
// BatchCreateServerRequest 批量导入服务器请求 // Logan.Li
|
||||||
type BatchCreateServerRequest struct {
|
type BatchCreateServerRequest struct {
|
||||||
Servers []BatchServerItem `json:"servers"`
|
Servers []BatchServerItem `json:"servers"`
|
||||||
RegionID *uint `json:"region_id"`
|
RegionID *uint `json:"region_id"`
|
||||||
AuthType string `json:"auth_type"`
|
AuthType string `json:"auth_type"`
|
||||||
CredentialID *uint `json:"credential_id"` // 凭据ID,非空则解密后写入每台服务器
|
CredentialID *uint `json:"credential_id"` // 兼容旧字段:默认 SSH 凭据 // Logan.Li
|
||||||
|
SSHCredentialID *uint `json:"ssh_credential_id"` // Linux 默认 SSH 凭据 // Logan.Li
|
||||||
|
RDPCredentialID *uint `json:"rdp_credential_id"` // Windows 默认 RDP 凭据 // Logan.Li
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchCreateResult 批量导入结果 // Logan.Li
|
// BatchCreateResult 批量导入结果 // Logan.Li
|
||||||
@@ -217,9 +230,9 @@ type ServerStats struct {
|
|||||||
AvgMemory float64 `json:"avg_memory"`
|
AvgMemory float64 `json:"avg_memory"`
|
||||||
AvgDisk float64 `json:"avg_disk"`
|
AvgDisk float64 `json:"avg_disk"`
|
||||||
// 总资源概览(GB/核),用于仪表盘资源使用率卡片展示具体容量 // Logan.Li
|
// 总资源概览(GB/核),用于仪表盘资源使用率卡片展示具体容量 // Logan.Li
|
||||||
TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计
|
TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计
|
||||||
TotalMemory int64 `json:"total_memory"` // 内存总量 GB
|
TotalMemory int64 `json:"total_memory"` // 内存总量 GB
|
||||||
UsedMemory int64 `json:"used_memory"` // 内存已用 GB
|
UsedMemory int64 `json:"used_memory"` // 内存已用 GB
|
||||||
TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB
|
TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB
|
||||||
UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB
|
UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB
|
||||||
}
|
}
|
||||||
@@ -4,16 +4,17 @@ import "time"
|
|||||||
|
|
||||||
// SessionCommand 会话命令记录(JumpServer 式命令审计) // Logan.Li
|
// SessionCommand 会话命令记录(JumpServer 式命令审计) // Logan.Li
|
||||||
type SessionCommand struct {
|
type SessionCommand struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
SessionID uint `gorm:"index" json:"session_id"` // 关联终端会话
|
SessionID uint `gorm:"index" json:"session_id"` // 关联终端会话
|
||||||
UserID *uint `json:"user_id"`
|
SessionType string `gorm:"size:32;default:terminal" json:"session_type"` // terminal/rdp,用于区分回放入口 // Logan.Li
|
||||||
Username string `gorm:"size:64" json:"username"`
|
UserID *uint `json:"user_id"`
|
||||||
ServerID uint `json:"server_id"`
|
Username string `gorm:"size:64" json:"username"`
|
||||||
ServerName string `gorm:"size:128" json:"server_name"`
|
ServerID uint `json:"server_id"`
|
||||||
Command string `gorm:"type:text" json:"command"` // 执行的原始命令
|
ServerName string `gorm:"size:128" json:"server_name"`
|
||||||
OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li
|
Command string `gorm:"type:text" json:"command"` // 执行的原始命令
|
||||||
Timestamp time.Time `json:"timestamp"` // 命令执行时间
|
OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Timestamp time.Time `json:"timestamp"` // 命令执行时间
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (SessionCommand) TableName() string {
|
func (SessionCommand) TableName() string {
|
||||||
|
|||||||
@@ -18,13 +18,42 @@ import (
|
|||||||
"ezops/pkg/agentcli/llm"
|
"ezops/pkg/agentcli/llm"
|
||||||
"ezops/pkg/agentcli/safety"
|
"ezops/pkg/agentcli/safety"
|
||||||
"ezops/pkg/agentcli/tools"
|
"ezops/pkg/agentcli/tools"
|
||||||
|
k8sclient "ezops/pkg/kubernetesclient"
|
||||||
"ezops/pkg/remoteexec"
|
"ezops/pkg/remoteexec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EngineConfig 引擎配置(由 controller 从 model.LLMConfig + model.Server 构建后注入) // Logan.Li
|
// RunMode Agent 运行模式 // Logan.Li
|
||||||
|
const (
|
||||||
|
RunModeHost = "host"
|
||||||
|
RunModeKubernetes = "kubernetes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeRunMode 规范化运行模式(兼容旧 k8s 参数) // Logan.Li
|
||||||
|
func NormalizeRunMode(mode string) string {
|
||||||
|
if mode == "k8s" || mode == RunModeKubernetes {
|
||||||
|
return RunModeKubernetes
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
return RunModeHost
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// KubernetesTarget Kubernetes 模式目标上下文(由 controller 注入) // Logan.Li
|
||||||
|
type KubernetesTarget struct {
|
||||||
|
Client *k8sclient.Client
|
||||||
|
ClusterName string
|
||||||
|
Namespace string
|
||||||
|
PodName string
|
||||||
|
Container string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineConfig 引擎配置(由 controller 从 model.LLMConfig + 目标上下文构建后注入) // Logan.Li
|
||||||
type EngineConfig struct {
|
type EngineConfig struct {
|
||||||
|
Mode string // host | kubernetes
|
||||||
LLM llm.Config // LLM 客户端配置(endpoint 已解析)
|
LLM llm.Config // LLM 客户端配置(endpoint 已解析)
|
||||||
SSH remoteexec.SSHConfig // 目标主机 SSH 配置(含凭据)
|
SSH remoteexec.SSHConfig // 主机模式 SSH 配置
|
||||||
|
Kubernetes *KubernetesTarget // Kubernetes 模式目标
|
||||||
SafetyMode safety.Mode // 安全模式(默认 balanced)
|
SafetyMode safety.Mode // 安全模式(默认 balanced)
|
||||||
Core *core.Config // ReAct 配置(nil 用默认)
|
Core *core.Config // ReAct 配置(nil 用默认)
|
||||||
MemoryDir string // Evolver 记忆目录(空则 data/agentcli)
|
MemoryDir string // Evolver 记忆目录(空则 data/agentcli)
|
||||||
@@ -47,25 +76,40 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
|
|||||||
if cfg.MemoryDir != "" {
|
if cfg.MemoryDir != "" {
|
||||||
coreCfg.MemoryDir = cfg.MemoryDir
|
coreCfg.MemoryDir = cfg.MemoryDir
|
||||||
}
|
}
|
||||||
mode := cfg.SafetyMode
|
safetyMode := cfg.SafetyMode
|
||||||
if mode == "" {
|
if safetyMode == "" {
|
||||||
mode = safety.ModeBalanced
|
safetyMode = safety.ModeBalanced
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM 客户端
|
// LLM 客户端
|
||||||
client := llm.NewClient(cfg.LLM)
|
client := llm.NewClient(cfg.LLM)
|
||||||
|
|
||||||
// 工具注册中心:ssh_execute(SSH 长连接)+ analyze_output
|
// 工具注册中心:按模式注册互斥工具集 // Logan.Li
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
|
runMode := NormalizeRunMode(cfg.Mode)
|
||||||
if err != nil {
|
coreCfg.Mode = runMode
|
||||||
return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
|
|
||||||
|
switch runMode {
|
||||||
|
case RunModeKubernetes:
|
||||||
|
if cfg.Kubernetes == nil || cfg.Kubernetes.Client == nil {
|
||||||
|
return nil, fmt.Errorf("Kubernetes 模式缺少集群客户端")
|
||||||
|
}
|
||||||
|
kt := cfg.Kubernetes
|
||||||
|
registry.Register(tools.NewKubernetesGetTool(kt.Client, kt.Namespace))
|
||||||
|
registry.Register(tools.NewPodLogsTool(kt.Client, kt.Namespace, kt.PodName, kt.Container))
|
||||||
|
registry.Register(tools.NewPodExecuteTool(kt.Client, kt.Namespace, kt.PodName, kt.Container, coreCfg.ToolTimeout))
|
||||||
|
registry.Register(tools.NewKubernetesActionTool(kt.Client, kt.Namespace))
|
||||||
|
default:
|
||||||
|
sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
|
||||||
|
}
|
||||||
|
registry.Register(sshTool)
|
||||||
}
|
}
|
||||||
registry.Register(sshTool)
|
|
||||||
registry.Register(tools.NewAnalyzeOutputTool())
|
registry.Register(tools.NewAnalyzeOutputTool())
|
||||||
|
|
||||||
// 安全控制器
|
// 安全控制器
|
||||||
sc := safety.NewController(mode)
|
sc := safety.NewController(safetyMode)
|
||||||
|
|
||||||
// Evolver(LLM 可用时启用学习;失败降级)
|
// Evolver(LLM 可用时启用学习;失败降级)
|
||||||
ev, err := evolver.NewEvolverEngine(coreCfg.MemoryDir, client)
|
ev, err := evolver.NewEvolverEngine(coreCfg.MemoryDir, client)
|
||||||
@@ -86,9 +130,9 @@ func (e *Engine) SetConfirmFn(fn safety.ConfirmFn) { e.safety.SetConfirmFn(fn) }
|
|||||||
// SetAutoApprove 测试用:自动批准(生产禁用) // Logan.Li
|
// SetAutoApprove 测试用:自动批准(生产禁用) // Logan.Li
|
||||||
func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) }
|
func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) }
|
||||||
|
|
||||||
// Run 执行任务(ReAct 循环);prior 为上一轮对话历史(不含 system),priorTokens 为累计 token // Logan.Li
|
// Run 执行任务(ReAct 循环);targetInfo 为目标上下文摘要;prior 为上一轮对话历史(不含 system) // Logan.Li
|
||||||
func (e *Engine) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
|
func (e *Engine) Run(ctx context.Context, query, targetInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
|
||||||
return e.agent.Run(ctx, query, serverInfo, prior, priorTokens)
|
return e.agent.Run(ctx, query, targetInfo, prior, priorTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvolveStats 进化统计(供审计展示) // Logan.Li
|
// EvolveStats 进化统计(供审计展示) // Logan.Li
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type RunResult struct {
|
|||||||
|
|
||||||
// Config Agent 运行配置 // Logan.Li
|
// Config Agent 运行配置 // Logan.Li
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Mode string // host | kubernetes
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
Temperature float64
|
Temperature float64
|
||||||
ToolTimeout time.Duration
|
ToolTimeout time.Duration
|
||||||
@@ -135,7 +136,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M
|
|||||||
|
|
||||||
// 工具描述
|
// 工具描述
|
||||||
toolDescs := a.buildToolDescriptions()
|
toolDescs := a.buildToolDescriptions()
|
||||||
systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx)
|
systemPrompt := prompt.BuildSystemPrompt(a.config.Mode, serverInfo, toolDescs, evolveCtx)
|
||||||
|
|
||||||
messages := []llm.Message{{Role: "system", Content: systemPrompt}}
|
messages := []llm.Message{{Role: "system", Content: systemPrompt}}
|
||||||
if len(prior) > 0 {
|
if len(prior) > 0 {
|
||||||
@@ -191,7 +192,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M
|
|||||||
}
|
}
|
||||||
messages = append(messages, llm.Message{
|
messages = append(messages, llm.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: "【系统提示】请立即调用 ssh_execute 工具在目标主机执行命令获取实际结果,不要仅用文字描述计划。",
|
Content: fmt.Sprintf("【系统提示】请立即调用 %s 工具获取实际结果,不要仅用文字描述计划。", a.primaryExecTool()),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -303,8 +304,8 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
|
|||||||
success = false
|
success = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 超长 ssh 输出自动分析,避免 token 爆炸 // Logan.Li
|
// 超长命令输出自动分析,避免 token 爆炸 // Logan.Li
|
||||||
if tc.Function.Name == "ssh_execute" && success && len(output) > a.autoAnalyzeThreshold() {
|
if (tc.Function.Name == "ssh_execute" || tc.Function.Name == "pod_execute") && success && len(output) > a.autoAnalyzeThreshold() {
|
||||||
if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" {
|
if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" {
|
||||||
output = analyzed
|
output = analyzed
|
||||||
}
|
}
|
||||||
@@ -364,6 +365,14 @@ func (a *Agent) roundNum(iter int) int {
|
|||||||
return a.config.RoundOffset + iter + 1
|
return a.config.RoundOffset + iter + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// primaryExecTool 返回当前模式下的主执行工具名 // Logan.Li
|
||||||
|
func (a *Agent) primaryExecTool() string {
|
||||||
|
if a.config.Mode == "kubernetes" || a.config.Mode == "k8s" {
|
||||||
|
return "kubernetes_get"
|
||||||
|
}
|
||||||
|
return "ssh_execute"
|
||||||
|
}
|
||||||
|
|
||||||
// lastToolOutput 取最近一条工具 observation,供最终答案为空时回退展示 // Logan.Li
|
// lastToolOutput 取最近一条工具 observation,供最终答案为空时回退展示 // Logan.Li
|
||||||
func lastToolOutput(messages []llm.Message) string {
|
func lastToolOutput(messages []llm.Message) string {
|
||||||
for i := len(messages) - 1; i >= 0; i-- {
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Package prompt 构建 Agent CLI 的 System Prompt(五层架构 + 动态记忆注入)。 // Logan.Li
|
// Package prompt 构建 Agent CLI 的 System Prompt(五层架构 + 动态记忆注入)。 // Logan.Li
|
||||||
// 设计参考 opsxcli internal/agent/prompt,角色改写为 aiops Web 运维 Agent 语境。
|
|
||||||
package prompt
|
package prompt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,34 +6,59 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildSystemPrompt 构建五层 System Prompt。 // Logan.Li
|
const runModeKubernetes = "kubernetes"
|
||||||
// - Layer 1: 角色定位(aiops 智能运维 Agent)
|
|
||||||
// - Layer 2: 安全规则(黑名单/高危确认/优先只读)
|
// normalizeMode 规范化运行模式(兼容旧 k8s,避免 import 循环) // Logan.Li
|
||||||
// - Layer 3: 工具说明(由 registry 动态生成)
|
func normalizeMode(mode string) string {
|
||||||
// - Layer 4: 工作方式(ReAct 循环:思考→执行→观察→失败则换法重试→直到解决)
|
if mode == "k8s" || mode == runModeKubernetes {
|
||||||
// - Layer 5: 记忆注入(Evolver 提供的历史经验)
|
return runModeKubernetes
|
||||||
//
|
}
|
||||||
// serverInfo:目标主机摘要;toolDescs:工具描述文本;evolveContext:历史经验。
|
if mode == "" {
|
||||||
func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string {
|
return "host"
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSystemPrompt 构建五层 System Prompt。mode: host|kubernetes // Logan.Li
|
||||||
|
func BuildSystemPrompt(mode, targetInfo, toolDescs, evolveContext string) string {
|
||||||
|
mode = normalizeMode(mode)
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Layer 1: 角色
|
// Layer 1: 角色
|
||||||
sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," +
|
if mode == runModeKubernetes {
|
||||||
"类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n")
|
sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级 Kubernetes 智能运维 Agent," +
|
||||||
sb.WriteString("你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," +
|
"运行在 Web 页面上,通过 kubernetes_get / pod_logs / pod_execute / kubernetes_action 操作真实 Kubernetes 集群。\n")
|
||||||
"并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n")
|
sb.WriteString("你的职责是:根据用户自然语言任务,自主查询集群/命名空间资源状态、分析事件与日志、" +
|
||||||
|
"必要时进入 Pod 容器排障,并通过 WebSocket 实时反馈每一步思考与结果。\n\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," +
|
||||||
|
"类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n")
|
||||||
|
sb.WriteString("你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," +
|
||||||
|
"并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
// 目标主机
|
// 目标上下文
|
||||||
if strings.TrimSpace(serverInfo) != "" {
|
if strings.TrimSpace(targetInfo) != "" {
|
||||||
sb.WriteString("【目标主机】\n" + serverInfo + "\n\n")
|
label := "目标主机"
|
||||||
|
if mode == runModeKubernetes {
|
||||||
|
label = "目标集群上下文"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("【%s】\n%s\n\n", label, targetInfo))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer 2: 安全规则
|
// Layer 2: 安全规则
|
||||||
sb.WriteString("【安全规则】\n")
|
sb.WriteString("【安全规则】\n")
|
||||||
sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n")
|
sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n")
|
||||||
sb.WriteString("2. 高危操作(reboot、systemctl restart、kill、卸载软件等)需要用户确认后才能执行。\n")
|
sb.WriteString("2. 高危操作(删除 Pod、重启 Deployment、扩缩容、换镜像等)需要用户确认后才能执行。\n")
|
||||||
sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n")
|
if mode == runModeKubernetes {
|
||||||
sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n\n")
|
sb.WriteString("3. 排障时优先使用只读工具:kubernetes_get 查资源状态、pod_logs 看日志;确认根因后再 pod_execute 或 kubernetes_action。\n")
|
||||||
|
sb.WriteString("4. 容器内可能没有 systemctl、宿主机路径;不要假设节点级命令可用。\n")
|
||||||
|
sb.WriteString("5. 未选定 Pod 时,用 kubernetes_get 和 pod_logs(带 pod 参数)排障;选定 Pod 后可直接 pod_execute。\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n")
|
||||||
|
sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Layer 3: 工具
|
// Layer 3: 工具
|
||||||
if strings.TrimSpace(toolDescs) != "" {
|
if strings.TrimSpace(toolDescs) != "" {
|
||||||
@@ -45,20 +69,24 @@ func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string {
|
|||||||
sb.WriteString("【工作方式(ReAct 循环)】\n")
|
sb.WriteString("【工作方式(ReAct 循环)】\n")
|
||||||
sb.WriteString("你采用「思考-行动-观察」循环解决任务:\n")
|
sb.WriteString("你采用「思考-行动-观察」循环解决任务:\n")
|
||||||
sb.WriteString("1. 思考:分析当前状态,决定下一步。在回复中先用简短文字说明你的判断和计划。\n")
|
sb.WriteString("1. 思考:分析当前状态,决定下一步。在回复中先用简短文字说明你的判断和计划。\n")
|
||||||
sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n")
|
if mode == runModeKubernetes {
|
||||||
sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n")
|
sb.WriteString("2. 行动:优先 kubernetes_get / pod_logs;需要容器内信息时用 pod_execute;变更用 kubernetes_action(一次一步)。\n")
|
||||||
sb.WriteString("4. 循环:如果命令失败或问题未解决,分析错误原因,换一种方法重试,直到真正解决。不要因一次失败就放弃。\n")
|
sb.WriteString("3. 观察:仔细阅读工具输出;pod_execute 输出含 [exit_code=0] 表示成功。\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n")
|
||||||
|
sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("4. 循环:如果失败或问题未解决,分析原因,换一种方法重试,直到真正解决。不要因一次失败就放弃。\n")
|
||||||
sb.WriteString("5. 收尾:问题解决后,用清晰的中文总结:问题根因、你做了什么、最终结果、后续建议。然后停止调用工具。\n\n")
|
sb.WriteString("5. 收尾:问题解决后,用清晰的中文总结:问题根因、你做了什么、最终结果、后续建议。然后停止调用工具。\n\n")
|
||||||
|
|
||||||
sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际命令输出判断," +
|
sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际工具输出判断," +
|
||||||
"禁止编造不存在的输出。命令超时或连接失败时,向用户说明并建议排查方向。\n\n")
|
"禁止编造不存在的输出。超时或 API 失败时,向用户说明并建议排查方向。\n\n")
|
||||||
|
|
||||||
// Layer 5: 记忆注入
|
// Layer 5: 记忆注入
|
||||||
if strings.TrimSpace(evolveContext) != "" {
|
if strings.TrimSpace(evolveContext) != "" {
|
||||||
sb.WriteString("【历史经验(来自自进化引擎)】\n" + evolveContext + "\n\n")
|
sb.WriteString("【历史经验(来自自进化引擎)】\n" + evolveContext + "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 输出要求
|
|
||||||
sb.WriteString("【输出要求】使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。")
|
sb.WriteString("【输出要求】使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func (t *AnalyzeOutputTool) Name() string { return "analyze_output" }
|
|||||||
// Description 给 LLM 的说明 // Logan.Li
|
// Description 给 LLM 的说明 // Logan.Li
|
||||||
func (t *AnalyzeOutputTool) Description() string {
|
func (t *AnalyzeOutputTool) Description() string {
|
||||||
return "分析一段(可能很长的)命令输出文本:统计行数、提取疑似错误/告警行、必要时截断保留首尾。" +
|
return "分析一段(可能很长的)命令输出文本:统计行数、提取疑似错误/告警行、必要时截断保留首尾。" +
|
||||||
"当 ssh_execute 返回内容过长时调用此工具提炼关键信息再继续推理。"
|
"当 ssh_execute 或 pod_execute 返回内容过长时调用此工具提炼关键信息再继续推理。"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parameters 参数定义 // Logan.Li
|
// Parameters 参数定义 // Logan.Li
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// kubernetes_action.go - Kubernetes 集群变更操作(高风险,需用户确认) // Logan.Li
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
k8sclient "ezops/pkg/kubernetesclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KubernetesActionTool 执行 Kubernetes 变更动作 // Logan.Li
|
||||||
|
type KubernetesActionTool struct {
|
||||||
|
client *k8sclient.Client
|
||||||
|
namespace string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKubernetesActionTool 创建变更工具 // Logan.Li
|
||||||
|
func NewKubernetesActionTool(client *k8sclient.Client, namespace string) *KubernetesActionTool {
|
||||||
|
return &KubernetesActionTool{client: client, namespace: namespace}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 工具名 // Logan.Li
|
||||||
|
func (t *KubernetesActionTool) Name() string { return "kubernetes_action" }
|
||||||
|
|
||||||
|
// Description 给 LLM 的说明 // Logan.Li
|
||||||
|
func (t *KubernetesActionTool) Description() string {
|
||||||
|
return "对 Kubernetes 资源执行变更操作(高风险,需用户确认)。" +
|
||||||
|
"action 支持: restart_deployment, scale_deployment, update_image, restart_statefulset, delete_pod, suspend_cronjob。" +
|
||||||
|
"变更前请先用 kubernetes_get 确认当前状态。"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters JSON Schema // Logan.Li
|
||||||
|
func (t *KubernetesActionTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "操作类型: restart_deployment|scale_deployment|update_image|restart_statefulset|delete_pod|suspend_cronjob",
|
||||||
|
},
|
||||||
|
"namespace": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "命名空间,默认会话绑定命名空间",
|
||||||
|
},
|
||||||
|
"name": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "资源名称(Deployment/StatefulSet/CronJob/Pod)",
|
||||||
|
},
|
||||||
|
"replicas": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "scale_deployment 目标副本数",
|
||||||
|
},
|
||||||
|
"image": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "update_image 新镜像地址",
|
||||||
|
},
|
||||||
|
"container": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "update_image 目标容器名,可省略(默认第一个)",
|
||||||
|
},
|
||||||
|
"suspend": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "suspend_cronjob: true=暂停, false=恢复",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action", "name"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiskLevel 静态高风险 // Logan.Li
|
||||||
|
func (t *KubernetesActionTool) RiskLevel() RiskLevel { return RiskHigh }
|
||||||
|
|
||||||
|
// Execute 执行变更 // Logan.Li
|
||||||
|
func (t *KubernetesActionTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||||
|
action := strings.ToLower(strings.TrimSpace(StringParam(args, "action")))
|
||||||
|
name := strings.TrimSpace(StringParam(args, "name"))
|
||||||
|
ns := strings.TrimSpace(StringParam(args, "namespace"))
|
||||||
|
if ns == "" {
|
||||||
|
ns = t.namespace
|
||||||
|
}
|
||||||
|
if action == "" || name == "" {
|
||||||
|
return &Result{Success: false, Error: "action 和 name 不能为空"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var msg string
|
||||||
|
switch action {
|
||||||
|
case "restart_deployment":
|
||||||
|
err = t.client.RestartDeployment(ns, name)
|
||||||
|
msg = fmt.Sprintf("已触发 Deployment %s/%s 滚动重启", ns, name)
|
||||||
|
case "scale_deployment":
|
||||||
|
replicas := IntParam(args, "replicas")
|
||||||
|
if replicas < 0 {
|
||||||
|
return &Result{Success: false, Error: "replicas 不能为负数"}, nil
|
||||||
|
}
|
||||||
|
err = t.client.ScaleDeployment(ns, name, replicas)
|
||||||
|
msg = fmt.Sprintf("已将 Deployment %s/%s 扩缩容至 %d 副本", ns, name, replicas)
|
||||||
|
case "update_image":
|
||||||
|
image := strings.TrimSpace(StringParam(args, "image"))
|
||||||
|
if image == "" {
|
||||||
|
return &Result{Success: false, Error: "update_image 需要 image 参数"}, nil
|
||||||
|
}
|
||||||
|
container := strings.TrimSpace(StringParam(args, "container"))
|
||||||
|
err = t.client.UpdateDeploymentImage(ns, name, container, image)
|
||||||
|
msg = fmt.Sprintf("已更新 Deployment %s/%s 镜像为 %s", ns, name, image)
|
||||||
|
case "restart_statefulset":
|
||||||
|
err = t.client.RestartStatefulSet(ns, name)
|
||||||
|
msg = fmt.Sprintf("已触发 StatefulSet %s/%s 滚动重启", ns, name)
|
||||||
|
case "delete_pod":
|
||||||
|
err = t.client.DeletePod(ns, name)
|
||||||
|
msg = fmt.Sprintf("已删除 Pod %s/%s(控制器将视情况重建)", ns, name)
|
||||||
|
case "suspend_cronjob":
|
||||||
|
suspend := true
|
||||||
|
if v, ok := args["suspend"].(bool); ok {
|
||||||
|
suspend = v
|
||||||
|
}
|
||||||
|
err = t.client.SuspendCronJob(ns, name, suspend)
|
||||||
|
if suspend {
|
||||||
|
msg = fmt.Sprintf("已暂停 CronJob %s/%s", ns, name)
|
||||||
|
} else {
|
||||||
|
msg = fmt.Sprintf("已恢复 CronJob %s/%s", ns, name)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return &Result{Success: false, Error: fmt.Sprintf("不支持的 action: %s", action)}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Success: false, Error: fmt.Sprintf("操作失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
return &Result{Success: true, Output: msg}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
// kubernetes_get.go - Kubernetes 集群/命名空间资源统一只读查询 // Logan.Li
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
k8sclient "ezops/pkg/kubernetesclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KubernetesGetTool 统一只读查询工具 // Logan.Li
|
||||||
|
type KubernetesGetTool struct {
|
||||||
|
client *k8sclient.Client
|
||||||
|
namespace string // 会话默认命名空间
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKubernetesGetTool 创建查询工具 // Logan.Li
|
||||||
|
func NewKubernetesGetTool(client *k8sclient.Client, namespace string) *KubernetesGetTool {
|
||||||
|
return &KubernetesGetTool{client: client, namespace: namespace}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 工具名 // Logan.Li
|
||||||
|
func (t *KubernetesGetTool) Name() string { return "kubernetes_get" }
|
||||||
|
|
||||||
|
// Description 给 LLM 的说明 // Logan.Li
|
||||||
|
func (t *KubernetesGetTool) Description() string {
|
||||||
|
return "查询 Kubernetes 集群或命名空间资源状态(只读)。" +
|
||||||
|
"resource 支持: nodes, pods, deployments, statefulsets, daemonsets, services, ingresses, events, namespaces, hpa, cronjobs。" +
|
||||||
|
"用于盘点节点/Pod 健康、副本状态、事件告警、服务路由等。返回结构化摘要,非原始 JSON。"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters JSON Schema // Logan.Li
|
||||||
|
func (t *KubernetesGetTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"resource": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "资源类型: nodes|pods|deployments|statefulsets|daemonsets|services|ingresses|events|namespaces|hpa|cronjobs",
|
||||||
|
},
|
||||||
|
"namespace": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "命名空间;pods/deployments 等命名空间级资源需要;空则用会话绑定命名空间",
|
||||||
|
},
|
||||||
|
"name": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选,查询单个资源名",
|
||||||
|
},
|
||||||
|
"label_selector": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选,标签筛选,格式 app=nginx 或 app=nginx,env=prod",
|
||||||
|
},
|
||||||
|
"status_filter": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选,Pod 状态筛选,如 Running、Pending、CrashLoopBackOff、NotRunning",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"resource"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiskLevel 只读 // Logan.Li
|
||||||
|
func (t *KubernetesGetTool) RiskLevel() RiskLevel { return RiskSafe }
|
||||||
|
|
||||||
|
// Execute 执行查询 // Logan.Li
|
||||||
|
func (t *KubernetesGetTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||||
|
resource := strings.ToLower(strings.TrimSpace(StringParam(args, "resource")))
|
||||||
|
if resource == "" {
|
||||||
|
return &Result{Success: false, Error: "resource 不能为空"}, nil
|
||||||
|
}
|
||||||
|
ns := strings.TrimSpace(StringParam(args, "namespace"))
|
||||||
|
if ns == "" {
|
||||||
|
ns = t.namespace
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(StringParam(args, "name"))
|
||||||
|
labelSel := parseLabelSelector(StringParam(args, "label_selector"))
|
||||||
|
statusFilter := strings.TrimSpace(StringParam(args, "status_filter"))
|
||||||
|
|
||||||
|
var output string
|
||||||
|
var err error
|
||||||
|
switch resource {
|
||||||
|
case "nodes", "node":
|
||||||
|
output, err = t.formatNodes(name)
|
||||||
|
case "pods", "pod":
|
||||||
|
output, err = t.formatPods(ns, name, labelSel, statusFilter)
|
||||||
|
case "deployments", "deployment", "deploy":
|
||||||
|
output, err = t.formatDeployments(ns, name)
|
||||||
|
case "statefulsets", "statefulset", "sts":
|
||||||
|
output, err = t.formatStatefulSets(ns, name)
|
||||||
|
case "daemonsets", "daemonset", "ds":
|
||||||
|
output, err = t.formatDaemonSets(ns, name)
|
||||||
|
case "services", "service", "svc":
|
||||||
|
output, err = t.formatServices(ns, name)
|
||||||
|
case "ingresses", "ingress", "ing":
|
||||||
|
output, err = t.formatIngresses(ns, name)
|
||||||
|
case "events", "event":
|
||||||
|
output, err = t.formatEvents(ns, name)
|
||||||
|
case "namespaces", "namespace", "ns":
|
||||||
|
output, err = t.formatNamespaces(name)
|
||||||
|
case "hpa", "hpas":
|
||||||
|
output, err = t.formatHPAs(ns, name)
|
||||||
|
case "cronjobs", "cronjob":
|
||||||
|
output, err = t.formatCronJobs(ns, name)
|
||||||
|
default:
|
||||||
|
return &Result{Success: false, Error: fmt.Sprintf("不支持的 resource: %s", resource)}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
|
return &Result{Success: true, Output: output}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLabelSelector(raw string) map[string]string {
|
||||||
|
out := make(map[string]string)
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if kv := strings.SplitN(part, "=", 2); len(kv) == 2 {
|
||||||
|
out[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func labelsMatch(labels, selector map[string]string) bool {
|
||||||
|
if len(selector) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for k, v := range selector {
|
||||||
|
if labels[k] != v {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func podDisplayStatus(p k8sclient.PodItem) string {
|
||||||
|
phase := p.Status.Phase
|
||||||
|
for _, cs := range p.Status.ContainerStatuses {
|
||||||
|
if !cs.Ready && cs.RestartCount > 0 {
|
||||||
|
return "CrashLoopBackOff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range p.Status.Conditions {
|
||||||
|
if c.Type == "Ready" && c.Status != "True" && c.Reason != "" {
|
||||||
|
return c.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return phase
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchPodStatus(status, filter string) bool {
|
||||||
|
if filter == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
f := strings.ToLower(filter)
|
||||||
|
s := strings.ToLower(status)
|
||||||
|
if f == "notrunning" {
|
||||||
|
return s != "running" && s != "succeeded" && s != "completed"
|
||||||
|
}
|
||||||
|
return strings.Contains(s, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatNodes(name string) (string, error) {
|
||||||
|
list, err := t.client.ListNodes()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Nodes (%d):\n", len(list.Items)))
|
||||||
|
for _, n := range list.Items {
|
||||||
|
if name != "" && n.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ready := "Unknown"
|
||||||
|
for _, c := range n.Status.Conditions {
|
||||||
|
if c.Type == "Ready" {
|
||||||
|
ready = c.Status
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ip := ""
|
||||||
|
for _, a := range n.Status.Addresses {
|
||||||
|
if a.Type == "InternalIP" {
|
||||||
|
ip = a.Address
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s Ready=%s IP=%s OS=%s kubelet=%s\n",
|
||||||
|
n.Metadata.Name, ready, ip, n.Status.NodeInfo.OSImage, n.Status.NodeInfo.KubeletVersion))
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatPods(ns, name string, labelSel map[string]string, statusFilter string) (string, error) {
|
||||||
|
list, err := t.client.ListPods()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
type row struct{ line string }
|
||||||
|
var rows []row
|
||||||
|
for _, p := range list.Items {
|
||||||
|
if ns != "" && p.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" && p.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !labelsMatch(p.Metadata.Labels, labelSel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st := podDisplayStatus(p)
|
||||||
|
if !matchPodStatus(st, statusFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
image := ""
|
||||||
|
if len(p.Spec.Containers) > 0 {
|
||||||
|
image = p.Spec.Containers[0].Image
|
||||||
|
}
|
||||||
|
restarts := 0
|
||||||
|
if len(p.Status.ContainerStatuses) > 0 {
|
||||||
|
restarts = p.Status.ContainerStatuses[0].RestartCount
|
||||||
|
}
|
||||||
|
rows = append(rows, row{line: fmt.Sprintf("- %s/%s %s restarts=%d node=%s ip=%s image=%s\n",
|
||||||
|
p.Metadata.Namespace, p.Metadata.Name, st, restarts, p.Spec.NodeName, p.Status.PodIP, image)})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].line < rows[j].line })
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Pods (%d):\n", len(rows)))
|
||||||
|
for _, r := range rows {
|
||||||
|
sb.WriteString(r.line)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
sb.WriteString("(无匹配 Pod)\n")
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatDeployments(ns, name string) (string, error) {
|
||||||
|
list, err := t.client.ListDeployments()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
count := 0
|
||||||
|
sb.WriteString("Deployments:\n")
|
||||||
|
for _, d := range list.Items {
|
||||||
|
if ns != "" && d.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" && d.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s/%s replicas=%d/%d ready=%d available=%d paused=%v\n",
|
||||||
|
d.Metadata.Namespace, d.Metadata.Name,
|
||||||
|
d.Status.ReadyReplicas, d.Spec.Replicas,
|
||||||
|
d.Status.ReadyReplicas, d.Status.AvailableReplicas, d.Spec.Paused))
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
sb.WriteString("(无匹配 Deployment)\n")
|
||||||
|
} else {
|
||||||
|
sb.Reset()
|
||||||
|
sb.WriteString(fmt.Sprintf("Deployments (%d):\n", count))
|
||||||
|
for _, d := range list.Items {
|
||||||
|
if ns != "" && d.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" && d.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s/%s replicas=%d/%d ready=%d available=%d paused=%v\n",
|
||||||
|
d.Metadata.Namespace, d.Metadata.Name,
|
||||||
|
d.Status.ReadyReplicas, d.Spec.Replicas,
|
||||||
|
d.Status.ReadyReplicas, d.Status.AvailableReplicas, d.Spec.Paused))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatStatefulSets(ns, name string) (string, error) {
|
||||||
|
list, err := t.client.ListStatefulSets()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, s := range list.Items {
|
||||||
|
if ns != "" && s.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" && s.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s/%s replicas=%d/%d ready=%d\n",
|
||||||
|
s.Metadata.Namespace, s.Metadata.Name, s.Status.ReadyReplicas, s.Spec.Replicas, s.Status.ReadyReplicas))
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("StatefulSets (%d):\n", len(lines)))
|
||||||
|
if len(lines) == 0 {
|
||||||
|
sb.WriteString("(无匹配 StatefulSet)\n")
|
||||||
|
} else {
|
||||||
|
for _, l := range lines {
|
||||||
|
sb.WriteString(l + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatDaemonSets(ns, name string) (string, error) {
|
||||||
|
list, err := t.client.ListDaemonSets()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, d := range list.Items {
|
||||||
|
if ns != "" && d.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" && d.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s/%s desired=%d ready=%d\n",
|
||||||
|
d.Metadata.Namespace, d.Metadata.Name, d.Status.DesiredNumberScheduled,
|
||||||
|
d.Status.NumberReady))
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("DaemonSets (%d):\n", len(lines)))
|
||||||
|
for _, l := range lines {
|
||||||
|
sb.WriteString(l + "\n")
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatServices(ns, name string) (string, error) {
|
||||||
|
list, err := t.client.ListServices()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, s := range list.Items {
|
||||||
|
if ns != "" && s.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" && s.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ports := ""
|
||||||
|
for i, p := range s.Spec.Ports {
|
||||||
|
if i > 0 {
|
||||||
|
ports += ","
|
||||||
|
}
|
||||||
|
ports += fmt.Sprintf("%d", p.Port)
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s/%s type=%s clusterIP=%s ports=[%s] selector=%v\n",
|
||||||
|
s.Metadata.Namespace, s.Metadata.Name, s.Spec.Type, s.Spec.ClusterIP, ports, s.Spec.Selector))
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Services (%d):\n", len(lines)))
|
||||||
|
for _, l := range lines {
|
||||||
|
sb.WriteString(l)
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatIngresses(ns, name string) (string, error) {
|
||||||
|
list, err := t.client.ListIngresses()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, ing := range list.Items {
|
||||||
|
if ns != "" && ing.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" && ing.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hosts := []string{}
|
||||||
|
for _, r := range ing.Spec.Rules {
|
||||||
|
if r.Host != "" {
|
||||||
|
hosts = append(hosts, r.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s/%s hosts=%v\n", ing.Metadata.Namespace, ing.Metadata.Name, hosts))
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Ingresses (%d):\n", len(lines)))
|
||||||
|
for _, l := range lines {
|
||||||
|
sb.WriteString(l + "\n")
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatEvents(ns, involvedName string) (string, error) {
|
||||||
|
list, err := t.client.ListEvents(ns)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
type evRow struct {
|
||||||
|
ts time.Time
|
||||||
|
line string
|
||||||
|
}
|
||||||
|
var rows []evRow
|
||||||
|
for _, e := range list.Items {
|
||||||
|
if involvedName != "" && e.InvolvedObject.Name != involvedName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts := e.LastTimestamp
|
||||||
|
if ts.IsZero() {
|
||||||
|
ts = e.EventTime
|
||||||
|
}
|
||||||
|
rows = append(rows, evRow{
|
||||||
|
ts: ts,
|
||||||
|
line: fmt.Sprintf("[%s] %s %s/%s: %s — %s (x%d)\n",
|
||||||
|
e.Type, e.Reason, e.InvolvedObject.Kind, e.InvolvedObject.Name, e.Message,
|
||||||
|
e.InvolvedObject.Namespace, e.Count),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].ts.After(rows[j].ts) })
|
||||||
|
if len(rows) > 30 {
|
||||||
|
rows = rows[:30]
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Events (recent %d):\n", len(rows)))
|
||||||
|
for _, r := range rows {
|
||||||
|
sb.WriteString(r.line)
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatNamespaces(name string) (string, error) {
|
||||||
|
names, err := t.client.ListNamespaces()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
var sb strings.Builder
|
||||||
|
count := 0
|
||||||
|
sb.WriteString("Namespaces:\n")
|
||||||
|
for _, n := range names {
|
||||||
|
if name != "" && n != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", n))
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
sb.WriteString("(无匹配命名空间)\n")
|
||||||
|
} else {
|
||||||
|
sb.Reset()
|
||||||
|
sb.WriteString(fmt.Sprintf("Namespaces (%d):\n", count))
|
||||||
|
for _, n := range names {
|
||||||
|
if name != "" && n != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatHPAs(ns, name string) (string, error) {
|
||||||
|
list, err := t.client.ListHPAs()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, h := range list.Items {
|
||||||
|
if ns != "" && h.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" && h.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s/%s target=%s/%s min=%d max=%d current=%d\n",
|
||||||
|
h.Metadata.Namespace, h.Metadata.Name,
|
||||||
|
h.Spec.ScaleTargetRef.Kind, h.Spec.ScaleTargetRef.Name,
|
||||||
|
h.Spec.MinReplicas, h.Spec.MaxReplicas, h.Status.CurrentReplicas))
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("HPAs (%d):\n", len(lines)))
|
||||||
|
for _, l := range lines {
|
||||||
|
sb.WriteString(l)
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KubernetesGetTool) formatCronJobs(ns, name string) (string, error) {
|
||||||
|
list, err := t.client.ListCronJobs()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, cj := range list.Items {
|
||||||
|
if ns != "" && cj.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" && cj.Metadata.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
suspended := false
|
||||||
|
if cj.Spec.Suspend != nil {
|
||||||
|
suspended = *cj.Spec.Suspend
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s/%s schedule=%s suspend=%v active=%d\n",
|
||||||
|
cj.Metadata.Namespace, cj.Metadata.Name, cj.Spec.Schedule, suspended, len(cj.Status.Active)))
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("CronJobs (%d):\n", len(lines)))
|
||||||
|
for _, l := range lines {
|
||||||
|
sb.WriteString(l)
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// pod_execute.go - 在目标 Pod 容器内执行 shell 命令 // Logan.Li
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
k8sclient "ezops/pkg/kubernetesclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PodExecuteTool 在 Pod 容器内执行命令(K8s exec WebSocket 协议) // Logan.Li
|
||||||
|
type PodExecuteTool struct {
|
||||||
|
client *k8sclient.Client
|
||||||
|
namespace string
|
||||||
|
podName string
|
||||||
|
container string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPodExecuteTool 创建 Pod 执行工具 // Logan.Li
|
||||||
|
func NewPodExecuteTool(client *k8sclient.Client, namespace, podName, container string, timeout time.Duration) *PodExecuteTool {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
return &PodExecuteTool{
|
||||||
|
client: client, namespace: namespace, podName: podName,
|
||||||
|
container: container, timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 工具名 // Logan.Li
|
||||||
|
func (t *PodExecuteTool) Name() string { return "pod_execute" }
|
||||||
|
|
||||||
|
// Description 给 LLM 的工具说明 // Logan.Li
|
||||||
|
func (t *PodExecuteTool) Description() string {
|
||||||
|
return "在已选定的 Kubernetes Pod 容器内执行 shell 命令并返回 stdout+stderr。" +
|
||||||
|
"用于容器内排障(查看进程/配置/连通性)、应用调试。" +
|
||||||
|
"注意:容器内可能没有 systemctl 或宿主机路径;多容器 Pod 可通过 container 参数指定。" +
|
||||||
|
"高危命令需用户确认。"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters JSON Schema // Logan.Li
|
||||||
|
func (t *PodExecuteTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"command": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要执行的 shell 命令,例如 ps aux、cat /etc/nginx/nginx.conf、curl -s localhost:8080/health",
|
||||||
|
},
|
||||||
|
"container": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选,多容器 Pod 时指定容器名;默认使用会话绑定的容器",
|
||||||
|
},
|
||||||
|
"timeout": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "命令超时秒数(默认 60)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"command"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiskLevel 静态风险 // Logan.Li
|
||||||
|
func (t *PodExecuteTool) RiskLevel() RiskLevel { return RiskHigh }
|
||||||
|
|
||||||
|
// RiskLevelForArgs 依据命令动态评估风险 // Logan.Li
|
||||||
|
func (t *PodExecuteTool) RiskLevelForArgs(args map[string]interface{}) RiskLevel {
|
||||||
|
return AssessBashRisk(StringParam(args, "command"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute 在 Pod 内执行命令 // Logan.Li
|
||||||
|
func (t *PodExecuteTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||||
|
command := strings.TrimSpace(StringParam(args, "command"))
|
||||||
|
if command == "" {
|
||||||
|
return &Result{Success: false, Error: "参数 command 不能为空"}, nil
|
||||||
|
}
|
||||||
|
if t.podName == "" {
|
||||||
|
return &Result{Success: false, Error: "未绑定目标 Pod,请在前端选择 Pod 或在命令前先 kubernetes_get 确认"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
container := strings.TrimSpace(StringParam(args, "container"))
|
||||||
|
if container == "" {
|
||||||
|
container = t.container
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := t.timeout
|
||||||
|
if sec := IntParam(args, "timeout"); sec > 0 {
|
||||||
|
timeout = time.Duration(sec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
execCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 用 sh -c 保持 shell 语义,并嵌入 exit_code 与 ssh_execute 对齐 // Logan.Li
|
||||||
|
wrapped := command + "; echo [exit_code=$?]"
|
||||||
|
done := make(chan struct {
|
||||||
|
res *k8sclient.ExecResult
|
||||||
|
err error
|
||||||
|
}, 1)
|
||||||
|
go func() {
|
||||||
|
r, err := t.client.ExecInPod(t.namespace, t.podName, container, []string{"sh", "-c", wrapped})
|
||||||
|
done <- struct {
|
||||||
|
res *k8sclient.ExecResult
|
||||||
|
err error
|
||||||
|
}{r, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var result *k8sclient.ExecResult
|
||||||
|
var err error
|
||||||
|
select {
|
||||||
|
case <-execCtx.Done():
|
||||||
|
return &Result{Success: false, Error: "命令执行超时"}, nil
|
||||||
|
case out := <-done:
|
||||||
|
result, err = out.res, out.err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Success: false, Error: fmt.Sprintf("Pod exec 失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
output := strings.TrimSpace(result.Stdout)
|
||||||
|
if result.Stderr != "" {
|
||||||
|
if output != "" {
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
output += strings.TrimSpace(result.Stderr)
|
||||||
|
}
|
||||||
|
success := strings.Contains(output, "[exit_code=0]")
|
||||||
|
if !success && !strings.Contains(output, "[exit_code=") {
|
||||||
|
// 无 exit_code 标记时,无 stderr 视为成功 // Logan.Li
|
||||||
|
success = strings.TrimSpace(result.Stderr) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Result{Success: success, Output: output}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// pod_logs.go - 获取 Pod 容器日志(只读) // Logan.Li
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
k8sclient "ezops/pkg/kubernetesclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PodLogsTool 拉取 Pod 日志 // Logan.Li
|
||||||
|
type PodLogsTool struct {
|
||||||
|
client *k8sclient.Client
|
||||||
|
namespace string
|
||||||
|
podName string
|
||||||
|
container string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPodLogsTool 创建日志工具 // Logan.Li
|
||||||
|
func NewPodLogsTool(client *k8sclient.Client, namespace, podName, container string) *PodLogsTool {
|
||||||
|
return &PodLogsTool{client: client, namespace: namespace, podName: podName, container: container}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 工具名 // Logan.Li
|
||||||
|
func (t *PodLogsTool) Name() string { return "pod_logs" }
|
||||||
|
|
||||||
|
// Description 给 LLM 的说明 // Logan.Li
|
||||||
|
func (t *PodLogsTool) Description() string {
|
||||||
|
return "获取目标 Pod 容器的最近日志(只读)。排障时优先使用此工具,比 pod_execute tail 更高效。" +
|
||||||
|
"未选 Pod 时需通过 name 参数指定 Pod 名。"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters JSON Schema // Logan.Li
|
||||||
|
func (t *PodLogsTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"pod": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Pod 名称;会话已绑定 Pod 时可省略",
|
||||||
|
},
|
||||||
|
"container": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "容器名;多容器时指定,默认会话绑定容器",
|
||||||
|
},
|
||||||
|
"tail_lines": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "返回最近行数,默认 200,最大 2000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiskLevel 只读安全 // Logan.Li
|
||||||
|
func (t *PodLogsTool) RiskLevel() RiskLevel { return RiskSafe }
|
||||||
|
|
||||||
|
// Execute 拉取日志 // Logan.Li
|
||||||
|
func (t *PodLogsTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||||
|
pod := strings.TrimSpace(StringParam(args, "pod"))
|
||||||
|
if pod == "" {
|
||||||
|
pod = t.podName
|
||||||
|
}
|
||||||
|
if pod == "" {
|
||||||
|
return &Result{Success: false, Error: "请指定 pod 参数或在前端选择目标 Pod"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
container := strings.TrimSpace(StringParam(args, "container"))
|
||||||
|
if container == "" {
|
||||||
|
container = t.container
|
||||||
|
}
|
||||||
|
|
||||||
|
tail := IntParam(args, "tail_lines")
|
||||||
|
if tail <= 0 {
|
||||||
|
tail = 200
|
||||||
|
}
|
||||||
|
if tail > 2000 {
|
||||||
|
tail = 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := t.client.GetPodLogs(t.namespace, pod, container, tail)
|
||||||
|
if err != nil {
|
||||||
|
return &Result{Success: false, Error: fmt.Sprintf("获取日志失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(logs) == "" {
|
||||||
|
return &Result{Success: true, Output: "(日志为空)"}, nil
|
||||||
|
}
|
||||||
|
return &Result{Success: true, Output: logs}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// 云账单同步引擎 — 从云账号 AK 拉取日账单写入本地 // Logan.Li
|
||||||
|
package billingsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/cloud"
|
||||||
|
"ezops/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result 同步结果
|
||||||
|
type Result struct {
|
||||||
|
BillingDate string `json:"billing_date"`
|
||||||
|
Accounts int `json:"accounts"`
|
||||||
|
Created int `json:"created"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yesterday 昨日日期 YYYY-MM-DD
|
||||||
|
func Yesterday() string {
|
||||||
|
return time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncDate 同步指定日期账单(全部或单个活跃云账号)
|
||||||
|
func SyncDate(db *gorm.DB, billingDate string, accountID *uint) Result {
|
||||||
|
result := Result{
|
||||||
|
BillingDate: billingDate,
|
||||||
|
Errors: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
var accounts []model.CloudAccount
|
||||||
|
q := db.Where("status = ?", "active")
|
||||||
|
if accountID != nil {
|
||||||
|
q = q.Where("id = ?", *accountID)
|
||||||
|
}
|
||||||
|
q.Find(&accounts)
|
||||||
|
result.Accounts = len(accounts)
|
||||||
|
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
result.Errors = append(result.Errors, "没有可同步的活跃云账号")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, account := range accounts {
|
||||||
|
created, updated, skipped, err := syncAccount(db, account, billingDate)
|
||||||
|
result.Created += created
|
||||||
|
result.Updated += updated
|
||||||
|
result.Skipped += skipped
|
||||||
|
if err != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s(%s): %s", account.Name, account.Provider, err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryMessage 生成执行摘要文案
|
||||||
|
func SummaryMessage(r Result) string {
|
||||||
|
msg := fmt.Sprintf("%s 新增 %d 条,更新 %d 条", r.BillingDate, r.Created, r.Updated)
|
||||||
|
if len(r.Errors) > 0 {
|
||||||
|
msg += fmt.Sprintf(";%d 个账号异常", len(r.Errors))
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (created, updated, skipped int, err error) {
|
||||||
|
fetcher := cloud.NewBillingFetcher(account.Provider, account.AccessKey, account.SecretKey)
|
||||||
|
if fetcher == nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("暂不支持该云厂商账单同步")
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := fetcher.FetchDailyBill(billingDate)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return 0, 0, 1, fmt.Errorf("该日期暂无账单数据(可能尚未出账)")
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID := account.ID
|
||||||
|
accountName := account.Name
|
||||||
|
|
||||||
|
for _, rec := range records {
|
||||||
|
var existing model.CloudBilling
|
||||||
|
findErr := db.Where(
|
||||||
|
"cloud_account_id = ? AND billing_date = ? AND provider = ? AND service = ? AND region = ?",
|
||||||
|
accountID, billingDate, rec.Provider, rec.Service, rec.Region,
|
||||||
|
).Take(&existing).Error
|
||||||
|
|
||||||
|
if findErr == gorm.ErrRecordNotFound {
|
||||||
|
row := model.CloudBilling{
|
||||||
|
CloudAccountID: &accountID,
|
||||||
|
AccountName: accountName,
|
||||||
|
Provider: rec.Provider,
|
||||||
|
BillingDate: billingDate,
|
||||||
|
Cost: rec.Cost,
|
||||||
|
Currency: rec.Currency,
|
||||||
|
Service: rec.Service,
|
||||||
|
Region: rec.Region,
|
||||||
|
Description: rec.Description,
|
||||||
|
}
|
||||||
|
if row.Currency == "" {
|
||||||
|
row.Currency = "CNY"
|
||||||
|
}
|
||||||
|
if db.Create(&row).Error == nil {
|
||||||
|
created++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if findErr != nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.Model(&existing).Updates(map[string]interface{}{
|
||||||
|
"cost": rec.Cost,
|
||||||
|
"currency": rec.Currency,
|
||||||
|
"description": rec.Description,
|
||||||
|
"account_name": accountName,
|
||||||
|
}).Error == nil {
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
monthStart := billingDate[:7] + "-01"
|
||||||
|
var monthCost float64
|
||||||
|
db.Model(&model.CloudBilling{}).
|
||||||
|
Where("cloud_account_id = ? AND billing_date >= ?", accountID, monthStart).
|
||||||
|
Select("COALESCE(SUM(cost), 0)").Scan(&monthCost)
|
||||||
|
db.Model(&account).Update("monthly_cost", monthCost)
|
||||||
|
|
||||||
|
return created, updated, skipped, nil
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"ezops/model"
|
"ezops/model"
|
||||||
"ezops/pkg/appdir"
|
"ezops/pkg/appdir"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
@@ -91,8 +91,8 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&model.KubernetesIngress{},
|
&model.KubernetesIngress{},
|
||||||
&model.AlertRule{},
|
&model.AlertRule{},
|
||||||
&model.AlertEvent{},
|
&model.AlertEvent{},
|
||||||
&model.AlertContact{}, // 报警联系人 // Logan.Li
|
&model.AlertContact{}, // 报警联系人 // Logan.Li
|
||||||
&model.AlertContactGroup{}, // 报警联系组 // Logan.Li
|
&model.AlertContactGroup{}, // 报警联系组 // Logan.Li
|
||||||
&model.AlertNotifyPolicy{}, // 告警集合通知策略 // Logan.Li
|
&model.AlertNotifyPolicy{}, // 告警集合通知策略 // Logan.Li
|
||||||
&model.AlertNotifySettings{}, // 告警消息模版 // Logan.Li
|
&model.AlertNotifySettings{}, // 告警消息模版 // Logan.Li
|
||||||
&model.NotifyChannel{},
|
&model.NotifyChannel{},
|
||||||
@@ -110,9 +110,9 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&model.NavigationGroup{},
|
&model.NavigationGroup{},
|
||||||
&model.NavigationLink{},
|
&model.NavigationLink{},
|
||||||
&model.AuditLog{},
|
&model.AuditLog{},
|
||||||
&model.AuditSettings{}, // 审计保留策略 // Logan.Li
|
&model.AuditSettings{}, // 审计保留策略 // Logan.Li
|
||||||
&model.LoginLog{}, // 登录日志 // Logan.Li
|
&model.LoginLog{}, // 登录日志 // Logan.Li
|
||||||
&model.SystemTaskStatus{}, // 内置系统任务状态 // Logan.Li
|
&model.SystemTaskStatus{}, // 内置系统任务状态 // Logan.Li
|
||||||
&model.LDAPConfig{},
|
&model.LDAPConfig{},
|
||||||
&model.ResourceModel{},
|
&model.ResourceModel{},
|
||||||
&model.CloudBilling{},
|
&model.CloudBilling{},
|
||||||
@@ -126,54 +126,55 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&model.AnsibleInventory{},
|
&model.AnsibleInventory{},
|
||||||
&model.LogSource{},
|
&model.LogSource{},
|
||||||
&model.LogEntry{},
|
&model.LogEntry{},
|
||||||
&model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
&model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
||||||
&model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
&model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
||||||
&model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
|
&model.RDPSession{}, // RDP 图形会话审计 // Logan.Li
|
||||||
&model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
&model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
|
||||||
&model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
|
&model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
||||||
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
|
&model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
|
||||||
&model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
|
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
|
||||||
&model.MonitorMetric{}, // 监控指标定义 // Logan.Li
|
&model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
|
||||||
&model.MetricValue{}, // 指标当前值 // Logan.Li
|
&model.MonitorMetric{}, // 监控指标定义 // Logan.Li
|
||||||
&model.MetricHistory{}, // 指标历史时序 // Logan.Li
|
&model.MetricValue{}, // 指标当前值 // Logan.Li
|
||||||
&model.HostGroup{}, // 主机组 // Logan.Li
|
&model.MetricHistory{}, // 指标历史时序 // Logan.Li
|
||||||
&model.SNMPDevice{}, // SNMP设备 // Logan.Li
|
&model.HostGroup{}, // 主机组 // Logan.Li
|
||||||
&model.SNMPTemplate{}, // SNMP模板 // Logan.Li
|
&model.SNMPDevice{}, // SNMP设备 // Logan.Li
|
||||||
&model.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li
|
&model.SNMPTemplate{}, // SNMP模板 // Logan.Li
|
||||||
&model.SNMPMetric{}, // SNMP采集指标 // Logan.Li
|
&model.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li
|
||||||
&model.SNMPInterface{}, // SNMP接口信息 // Logan.Li
|
&model.SNMPMetric{}, // SNMP采集指标 // Logan.Li
|
||||||
&model.SiteTask{}, // 站点拨测任务 // Logan.Li
|
&model.SNMPInterface{}, // SNMP接口信息 // Logan.Li
|
||||||
&model.SiteResult{}, // 拨测结果 // Logan.Li
|
&model.SiteTask{}, // 站点拨测任务 // Logan.Li
|
||||||
&model.SiteAlert{}, // 拨测告警 // Logan.Li
|
&model.SiteResult{}, // 拨测结果 // Logan.Li
|
||||||
&model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li
|
&model.SiteAlert{}, // 拨测告警 // Logan.Li
|
||||||
|
&model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li
|
||||||
&model.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
|
&model.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
|
||||||
&model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
|
&model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
|
||||||
&model.LogDataSource{}, // Loki / ES 日志数据源 // Logan.Li
|
&model.LogDataSource{}, // Loki / ES 日志数据源 // Logan.Li
|
||||||
&model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
|
&model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
|
||||||
&model.PipelineRepo{}, // Git 仓库认证 // Logan.Li
|
&model.PipelineRepo{}, // Git 仓库认证 // Logan.Li
|
||||||
&model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li
|
&model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li
|
||||||
&model.DeployEnvironment{}, // 发布环境分组 // Logan.Li
|
&model.DeployEnvironment{}, // 发布环境分组 // Logan.Li
|
||||||
&model.DeployPipelineMember{}, // 流水线成员 // Logan.Li
|
&model.DeployPipelineMember{}, // 流水线成员 // Logan.Li
|
||||||
&model.DeployUserGroup{}, // 用户组 // Logan.Li
|
&model.DeployUserGroup{}, // 用户组 // Logan.Li
|
||||||
&model.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
|
&model.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
|
||||||
&model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
|
&model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
|
||||||
&model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
|
&model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
|
||||||
&model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li
|
&model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li
|
||||||
&model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li
|
&model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li
|
||||||
&model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li
|
&model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li
|
||||||
&model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
|
&model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
|
||||||
&model.AlertCondition{}, // 告警条件 // Logan.Li
|
&model.AlertCondition{}, // 告警条件 // Logan.Li
|
||||||
&model.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
|
&model.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
|
||||||
&model.AlertUpgrade{}, // 告警升级策略 // Logan.Li
|
&model.AlertUpgrade{}, // 告警升级策略 // Logan.Li
|
||||||
&model.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li
|
&model.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li
|
||||||
&model.AlertSilence{}, // 告警静默 // Logan.Li
|
&model.AlertSilence{}, // 告警静默 // Logan.Li
|
||||||
&model.HealthProbe{}, // 健康探针配置 // Logan.Li
|
&model.HealthProbe{}, // 健康探针配置 // Logan.Li
|
||||||
&model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li
|
&model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li
|
||||||
&model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li
|
&model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li
|
||||||
&model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li
|
&model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li
|
||||||
&model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li
|
&model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li
|
||||||
&model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li
|
&model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li
|
||||||
&model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li
|
&model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
@@ -238,4 +239,26 @@ func applySchemaPatches(db *gorm.DB) {
|
|||||||
log.Printf("[DB] 补列 iam_ldap_configs.server_type 失败: %v", err)
|
log.Printf("[DB] 补列 iam_ldap_configs.server_type 失败: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 服务器 RDP/协议字段(旧库 AutoMigrate 可能未加列)// Logan.Li
|
||||||
|
patchServerColumn := func(field string, col string) {
|
||||||
|
if !m.HasColumn(&model.Server{}, col) {
|
||||||
|
if err := m.AddColumn(&model.Server{}, field); err != nil {
|
||||||
|
log.Printf("[DB] 补列 asset_servers.%s 失败: %v", col, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patchServerColumn("OSType", "os_type")
|
||||||
|
patchServerColumn("Protocol", "protocol")
|
||||||
|
patchServerColumn("RDPPort", "rdp_port")
|
||||||
|
|
||||||
|
// 将历史仅填 os=windows 的记录补齐协议字段 // Logan.Li
|
||||||
|
if m.HasColumn(&model.Server{}, "os_type") && m.HasColumn(&model.Server{}, "protocol") {
|
||||||
|
_ = db.Exec(`
|
||||||
|
UPDATE asset_servers
|
||||||
|
SET os_type = 'windows', protocol = 'rdp', rdp_port = IF(rdp_port IS NULL OR rdp_port = 0, 3389, rdp_port)
|
||||||
|
WHERE (os_type IS NULL OR os_type = '' OR protocol IS NULL OR protocol = '')
|
||||||
|
AND LOWER(os) LIKE '%windows%'
|
||||||
|
`).Error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# RDP 协议栈分层
|
||||||
|
|
||||||
|
`backend/pkg/rdp` 是 AIOps 资产远程桌面的协议层。当前固定使用自研 RDP 引擎,第三方 `grdp` 兜底和 `backend/third_party` 已移除。根包只保留兼容入口、引擎创建与临时会话编排,协议细节逐步迁入域目录。// Logan.Li
|
||||||
|
|
||||||
|
## 引擎选择
|
||||||
|
|
||||||
|
| 环境变量 | 行为 |
|
||||||
|
|----------|------|
|
||||||
|
| `RDP_ENGINE` | 保留兼容,不再参与引擎选择 |
|
||||||
|
| `RDP_DEBUG=1` | 打开协议级 trace 日志,统一输出 `[RDP:TRACE]` |
|
||||||
|
|
||||||
|
## 目录职责
|
||||||
|
|
||||||
|
| 目录 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `domain/` | 对外契约:连接配置、浏览器输入事件、服务端帧、引擎接口、诊断结果、录屏摘要 |
|
||||||
|
| `transport/` | TCP 上的 TPKT、X.224、TLS 升级与安全协议协商 |
|
||||||
|
| `credssp/` | NLA/CredSSP/NTLMv2 认证,自研 ASN.1 与 NTLM 消息处理 |
|
||||||
|
| `rle/` | RDP 位图 RLE 解压算法 |
|
||||||
|
| `trace/` | 协议级调试日志开关,避免高频 bitmap 默认刷屏 |
|
||||||
|
| 根包 | 引擎选择、兼容入口、临时会话编排、MCS/GCC、Fast-Path 收发、键鼠输入、位图解码、探针入口 |
|
||||||
|
|
||||||
|
## 依赖方向
|
||||||
|
|
||||||
|
控制器只依赖根包 `ezops/pkg/rdp` 的 `Engine`、`Config`、`ClientEvent`、`ServerFrame`。根包向内组合 `domain`、`transport`、`credssp`、`rle`。底层域目录不能反向依赖控制器、数据库、WebSocket 或审计模型。
|
||||||
|
|
||||||
|
## 后续拆分目标
|
||||||
|
|
||||||
|
1. 协议基础帧、TLS、X.224 只放 `transport/`。
|
||||||
|
2. 登录认证只放 `credssp/`,不得调用 Gin/GORM。
|
||||||
|
3. 浏览器与控制器共享的 DTO 先放 `domain/`,根包用类型别名兼容旧调用。
|
||||||
|
4. `pdu/` 承接 DemandActive、ConfirmActive、Synchronize、Control、FontList、BitmapUpdate 等状态机。
|
||||||
|
5. `graphics/` 承接 bitmap/RLE/PNG 或后续 raw frame 编码,输入只消费标准化矩形。
|
||||||
|
6. `input/` 承接鼠标、键盘、Unicode、组合键与自动执行。
|
||||||
|
7. `channels/` 承接 cliprdr、drdynvc、rdpgfx,避免虚拟通道逻辑污染会话主流程。
|
||||||
|
8. `recorder/` 承接 RDP 事件录制、回放和截图探针。
|
||||||
|
9. 新增测试优先覆盖底层域目录,再用根包探针做资产联调。
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"ezops/pkg/rdp/rle"
|
||||||
|
"ezops/pkg/rdp/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
bitmapCompression = 0x0001
|
||||||
|
noBitmapCompressionHdr = 0x0400
|
||||||
|
bitmapNoProcessing = 0x8000
|
||||||
|
fastPathUpdateBitmap = 0x1
|
||||||
|
fastPathUpdateSurfaceCmds = 0x4
|
||||||
|
pduType2Update = 0x02
|
||||||
|
)
|
||||||
|
|
||||||
|
// BitmapRect 表示一块待绘制到 Canvas 的位图区域 // Logan.Li
|
||||||
|
type BitmapRect struct {
|
||||||
|
DestLeft int
|
||||||
|
DestTop int
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
BitsPerPixel int
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func bppFromWire(v uint16) int {
|
||||||
|
switch v {
|
||||||
|
case 8:
|
||||||
|
return 1
|
||||||
|
case 15, 16:
|
||||||
|
return 2
|
||||||
|
case 24:
|
||||||
|
return 3
|
||||||
|
case 32:
|
||||||
|
return 4
|
||||||
|
default:
|
||||||
|
if v <= 4 {
|
||||||
|
return int(v)
|
||||||
|
}
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireBitmap struct {
|
||||||
|
DestLeft uint16
|
||||||
|
DestTop uint16
|
||||||
|
DestRight uint16
|
||||||
|
DestBottom uint16
|
||||||
|
Width uint16
|
||||||
|
Height uint16
|
||||||
|
BitsPerPixel uint16
|
||||||
|
Flags uint16
|
||||||
|
BitmapLength uint16
|
||||||
|
BitmapDataStream []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWireBitmap(r *bytes.Reader) (wireBitmap, error) {
|
||||||
|
var rect wireBitmap
|
||||||
|
var err error
|
||||||
|
rect.DestLeft, err = readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
rect.DestTop, err = readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
rect.DestRight, err = readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
rect.DestBottom, err = readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
rect.Width, err = readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
rect.Height, err = readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
rect.BitsPerPixel, err = readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
rect.Flags, err = readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
rect.BitmapLength, err = readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
ln := int(rect.BitmapLength)
|
||||||
|
if rect.Flags&bitmapCompression != 0 && rect.Flags&noBitmapCompressionHdr == 0 {
|
||||||
|
if _, err = readLE16(r); err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
mainBody, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
if _, err = readLE16(r); err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
if _, err = readLE16(r); err != nil {
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
ln = int(mainBody)
|
||||||
|
}
|
||||||
|
rect.BitmapDataStream = make([]byte, ln)
|
||||||
|
_, err = io.ReadFull(r, rect.BitmapDataStream)
|
||||||
|
return rect, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFastPathBitmap(payload []byte) ([]BitmapRect, error) {
|
||||||
|
r := bytes.NewReader(payload)
|
||||||
|
if _, err := readLE16(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]BitmapRect, 0, count)
|
||||||
|
for i := 0; i < int(count); i++ {
|
||||||
|
wb, err := readWireBitmap(r)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if rect, ok := decodeWireBitmap(wb); ok {
|
||||||
|
out = append(out, rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSlowPathBitmap(payload []byte) ([]BitmapRect, error) {
|
||||||
|
if len(payload) < 15 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
pduType := binaryLittleUint16(payload[2:4])
|
||||||
|
if pduType != pduTypeData {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
type2 := payload[14]
|
||||||
|
if type2 != pduType2Update {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
body := payload[18:]
|
||||||
|
if len(body) < 2 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
updateType := binaryLittleUint16(body[0:2])
|
||||||
|
if updateType != fastPathUpdateBitmap {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return parseFastPathBitmap(body[2:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeWireBitmap(wb wireBitmap) (rect BitmapRect, ok bool) {
|
||||||
|
defer func() {
|
||||||
|
if recover() != nil {
|
||||||
|
rect = BitmapRect{}
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
bpp := bppFromWire(wb.BitsPerPixel)
|
||||||
|
data := wb.BitmapDataStream
|
||||||
|
originalLen := len(data)
|
||||||
|
if wb.Flags&bitmapNoProcessing != 0 {
|
||||||
|
// Surface command 已是自上而下 BGRA // Logan.Li
|
||||||
|
} else if wb.Flags&bitmapCompression != 0 {
|
||||||
|
data = rle.DecompressInto(data, nil, int(wb.Width), int(wb.Height), bpp)
|
||||||
|
if len(data) == 0 {
|
||||||
|
trace.Logf("bitmap decode failed compressed rect=%dx%d@%d,%d wireBpp=%d bpp=%d flags=0x%04x original=%d", wb.Width, wb.Height, wb.DestLeft, wb.DestTop, wb.BitsPerPixel, bpp, wb.Flags, originalLen)
|
||||||
|
return BitmapRect{}, false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data = flipBitmapVertical(data, int(wb.Width), bpp, int(wb.Height))
|
||||||
|
}
|
||||||
|
if len(data) < int(wb.Width)*int(wb.Height)*bpp {
|
||||||
|
trace.Logf("bitmap decode short rect=%dx%d@%d,%d wireBpp=%d bpp=%d flags=0x%04x original=%d decoded=%d", wb.Width, wb.Height, wb.DestLeft, wb.DestTop, wb.BitsPerPixel, bpp, wb.Flags, originalLen, len(data))
|
||||||
|
return BitmapRect{}, false
|
||||||
|
}
|
||||||
|
if isMostlyBlack(data, bpp) && int(wb.Width) >= 128 && int(wb.Height) >= 128 {
|
||||||
|
trace.Logf("bitmap decode mostly black rect=%dx%d@%d,%d wireBpp=%d bpp=%d flags=0x%04x original=%d decoded=%d", wb.Width, wb.Height, wb.DestLeft, wb.DestTop, wb.BitsPerPixel, bpp, wb.Flags, originalLen, len(data))
|
||||||
|
}
|
||||||
|
return BitmapRect{
|
||||||
|
DestLeft: int(wb.DestLeft),
|
||||||
|
DestTop: int(wb.DestTop),
|
||||||
|
Width: int(wb.Width),
|
||||||
|
Height: int(wb.Height),
|
||||||
|
BitsPerPixel: bpp,
|
||||||
|
Data: data,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMostlyBlack(data []byte, bpp int) bool {
|
||||||
|
if len(data) == 0 || bpp <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
limit := len(data)
|
||||||
|
if limit > 256*1024 {
|
||||||
|
limit = 256 * 1024
|
||||||
|
}
|
||||||
|
nonBlack := 0
|
||||||
|
for i := 0; i+bpp-1 < limit; i += bpp {
|
||||||
|
switch bpp {
|
||||||
|
case 1:
|
||||||
|
if data[i] > 4 {
|
||||||
|
nonBlack++
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
if data[i] > 0 || data[i+1] > 0 {
|
||||||
|
nonBlack++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if data[i] > 4 || data[i+1] > 4 || data[i+2] > 4 {
|
||||||
|
nonBlack++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nonBlack > 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func flipBitmapVertical(data []byte, width, bpp, height int) []byte {
|
||||||
|
out := append([]byte(nil), data...)
|
||||||
|
stride := width * bpp
|
||||||
|
tmp := make([]byte, stride)
|
||||||
|
for y := 0; y < height/2; y++ {
|
||||||
|
top := y * stride
|
||||||
|
bot := (height - 1 - y) * stride
|
||||||
|
copy(tmp, out[top:top+stride])
|
||||||
|
copy(out[top:top+stride], out[bot:bot+stride])
|
||||||
|
copy(out[bot:bot+stride], tmp)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func bitmapRectPNG(rect BitmapRect) (string, error) {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, rect.Width, rect.Height))
|
||||||
|
pix := img.Pix
|
||||||
|
data := rect.Data
|
||||||
|
switch rect.BitsPerPixel {
|
||||||
|
case 1, 2:
|
||||||
|
n := rect.Width * rect.Height
|
||||||
|
for i := 0; i < n && i*2+1 < len(data); i++ {
|
||||||
|
// 保持 PNG 兼容路径与 native display 的 16bpp 大端 RGB565 解析一致。 // Logan.Li
|
||||||
|
c := binary.BigEndian.Uint16(data[i*2 : i*2+2])
|
||||||
|
r := uint8((c >> 11) & 0x1F)
|
||||||
|
g := uint8((c >> 5) & 0x3F)
|
||||||
|
b := uint8(c & 0x1F)
|
||||||
|
off := i * 4
|
||||||
|
pix[off] = (r << 3) | (r >> 2)
|
||||||
|
pix[off+1] = (g << 2) | (g >> 4)
|
||||||
|
pix[off+2] = (b << 3) | (b >> 2)
|
||||||
|
pix[off+3] = 0xFF
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
stride := rect.BitsPerPixel
|
||||||
|
n := rect.Width * rect.Height
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
s := i * stride
|
||||||
|
if s+2 >= len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
off := i * 4
|
||||||
|
pix[off+2] = data[s]
|
||||||
|
pix[off+1] = data[s+1]
|
||||||
|
pix[off] = data[s+2]
|
||||||
|
pix[off+3] = 0xFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func binaryLittleUint16(b []byte) uint16 {
|
||||||
|
return uint16(b[0]) | uint16(b[1])<<8
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/pkg/rdp/credssp"
|
||||||
|
"ezops/pkg/rdp/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProbeHandshake 执行 TCP + TPKT/X.224 + RDP 安全协议协商 // Logan.Li
|
||||||
|
func ProbeHandshake(ctx context.Context, cfg Config) (HandshakeInfo, error) {
|
||||||
|
port := cfg.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 3389
|
||||||
|
}
|
||||||
|
address := fmt.Sprintf("%s:%d", cfg.Host, port)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
var d net.Dialer
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||||
|
|
||||||
|
if err := transport.WriteTPKT(conn, transport.BuildConnectionRequest()); err != nil {
|
||||||
|
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||||
|
}
|
||||||
|
payload, err := transport.ReadTPKT(conn)
|
||||||
|
if err != nil {
|
||||||
|
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||||
|
}
|
||||||
|
result, err := transport.ParseConnectionConfirm(payload)
|
||||||
|
if err != nil {
|
||||||
|
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||||
|
}
|
||||||
|
return HandshakeInfo{
|
||||||
|
Address: address,
|
||||||
|
SelectedProtocol: result.ProtocolName(),
|
||||||
|
Duration: time.Since(start),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeTLSHandshake 执行 X.224 协商后升级 TLS,用于验证 Windows 10/Server NLA 前置链路 // Logan.Li
|
||||||
|
func ProbeTLSHandshake(ctx context.Context, cfg Config) (HandshakeInfo, error) {
|
||||||
|
port := cfg.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 3389
|
||||||
|
}
|
||||||
|
address := fmt.Sprintf("%s:%d", cfg.Host, port)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
var d net.Dialer
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||||
|
|
||||||
|
if err := transport.WriteTPKT(conn, transport.BuildConnectionRequestWithCookie(cfg.Username)); err != nil {
|
||||||
|
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||||
|
}
|
||||||
|
payload, err := transport.ReadTPKT(conn)
|
||||||
|
if err != nil {
|
||||||
|
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||||
|
}
|
||||||
|
result, err := transport.ParseConnectionConfirm(payload)
|
||||||
|
if err != nil {
|
||||||
|
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||||
|
}
|
||||||
|
if result.SelectedProtocol != transport.ProtocolTLS && result.SelectedProtocol != transport.ProtocolCredSSP {
|
||||||
|
return HandshakeInfo{Address: address, SelectedProtocol: result.ProtocolName(), Duration: time.Since(start)}, nil
|
||||||
|
}
|
||||||
|
tlsConn, err := transport.UpgradeTLS(conn, cfg.Host, 15*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return HandshakeInfo{Address: address, SelectedProtocol: result.ProtocolName(), Duration: time.Since(start)}, err
|
||||||
|
}
|
||||||
|
state := tlsConn.ConnectionState()
|
||||||
|
certSubject := ""
|
||||||
|
if len(state.PeerCertificates) > 0 {
|
||||||
|
certSubject = state.PeerCertificates[0].Subject.String()
|
||||||
|
}
|
||||||
|
_ = tlsConn.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||||
|
_, _ = io.ReadAll(tlsConn)
|
||||||
|
return HandshakeInfo{
|
||||||
|
Address: address,
|
||||||
|
SelectedProtocol: result.ProtocolName(),
|
||||||
|
Duration: time.Since(start),
|
||||||
|
TLSCipher: transport.TLSCipherSuiteName(state.CipherSuite),
|
||||||
|
CertSubject: certSubject,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeCredSSP 执行到 CredSSP/NTLMv2 登录完成,用于验证 Windows 10/Server NLA 凭据 // Logan.Li
|
||||||
|
func ProbeCredSSP(ctx context.Context, cfg Config) (HandshakeInfo, error) {
|
||||||
|
port := cfg.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 3389
|
||||||
|
}
|
||||||
|
address := fmt.Sprintf("%s:%d", cfg.Host, port)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
strategies := []credssp.Options{
|
||||||
|
{Version: 6, PubKeyStyle: "binding"},
|
||||||
|
{Version: 6, PubKeyStyle: "binding", UseCBT: true},
|
||||||
|
{Version: 6, PubKeyStyle: "binding", ForceMIC: true},
|
||||||
|
{Version: 2, PubKeyStyle: "raw"},
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, opts := range strategies {
|
||||||
|
info, err := probeCredSSPOnce(ctx, cfg, address, opts)
|
||||||
|
if err == nil {
|
||||||
|
info.Duration = time.Since(start)
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("CredSSP 认证失败")
|
||||||
|
}
|
||||||
|
return HandshakeInfo{Address: address, Duration: time.Since(start)}, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeCredSSPOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (HandshakeInfo, error) {
|
||||||
|
tlsConn, result, err := dialCredSSPOnce(ctx, cfg, address, opts)
|
||||||
|
if err != nil {
|
||||||
|
return HandshakeInfo{Address: address}, err
|
||||||
|
}
|
||||||
|
defer tlsConn.Close()
|
||||||
|
state := tlsConn.ConnectionState()
|
||||||
|
certSubject := ""
|
||||||
|
if len(state.PeerCertificates) > 0 {
|
||||||
|
certSubject = state.PeerCertificates[0].Subject.String()
|
||||||
|
}
|
||||||
|
return HandshakeInfo{
|
||||||
|
Address: address,
|
||||||
|
SelectedProtocol: result.ProtocolName(),
|
||||||
|
TLSCipher: transport.TLSCipherSuiteName(state.CipherSuite),
|
||||||
|
CertSubject: certSubject,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialCredSSPOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (*tls.Conn, transport.NegotiationResult, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, transport.NegotiationResult{}, err
|
||||||
|
}
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
|
||||||
|
if err := transport.WriteTPKT(conn, transport.BuildConnectionRequestWithCookie(cfg.Username)); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, transport.NegotiationResult{}, err
|
||||||
|
}
|
||||||
|
payload, err := transport.ReadTPKT(conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, transport.NegotiationResult{}, err
|
||||||
|
}
|
||||||
|
result, err := transport.ParseConnectionConfirm(payload)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, transport.NegotiationResult{}, err
|
||||||
|
}
|
||||||
|
if result.SelectedProtocol != transport.ProtocolCredSSP {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, result, fmt.Errorf("目标未选择 CredSSP/NLA: %s", result.ProtocolName())
|
||||||
|
}
|
||||||
|
tlsConn, err := transport.UpgradeTLS(conn, cfg.Host, 30*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, result, err
|
||||||
|
}
|
||||||
|
if err := credssp.AuthenticateWithOptions(tlsConn, cfg.Domain, cfg.Username, cfg.Password, opts); err != nil {
|
||||||
|
_ = tlsConn.Close()
|
||||||
|
return nil, result, err
|
||||||
|
}
|
||||||
|
_ = tlsConn.SetDeadline(time.Time{})
|
||||||
|
return tlsConn, result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package credssp
|
||||||
|
|
||||||
|
import "encoding/asn1"
|
||||||
|
|
||||||
|
const defaultCredSSPVersion = 6
|
||||||
|
|
||||||
|
// negoToken 是 CredSSP TSRequest 中的单个协商令牌 // Logan.Li
|
||||||
|
type negoToken struct {
|
||||||
|
Data []byte `asn1:"explicit,tag:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsRequest 是 MS-CSSP 2.2.1.2 TSRequest 的最小实现 // Logan.Li
|
||||||
|
type tsRequest struct {
|
||||||
|
Version int `asn1:"explicit,tag:0"`
|
||||||
|
NegoTokens []negoToken `asn1:"optional,explicit,tag:1"`
|
||||||
|
AuthInfo []byte `asn1:"optional,explicit,tag:2"`
|
||||||
|
PubKeyAuth []byte `asn1:"optional,explicit,tag:3"`
|
||||||
|
ErrorCode int `asn1:"optional,explicit,tag:4"`
|
||||||
|
ClientNonce []byte `asn1:"optional,explicit,tag:5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tsCredentials struct {
|
||||||
|
CredType int `asn1:"explicit,tag:0"`
|
||||||
|
Credentials []byte `asn1:"explicit,tag:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tsPasswordCreds struct {
|
||||||
|
DomainName []byte `asn1:"explicit,tag:0"`
|
||||||
|
UserName []byte `asn1:"explicit,tag:1"`
|
||||||
|
Password []byte `asn1:"explicit,tag:2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeTSRequest(tokens [][]byte, authInfo, pubKeyAuth []byte) ([]byte, error) {
|
||||||
|
return encodeTSRequestWithNonce(tokens, authInfo, pubKeyAuth, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeTSRequestWithNonce(tokens [][]byte, authInfo, pubKeyAuth, clientNonce []byte) ([]byte, error) {
|
||||||
|
return encodeTSRequestVersion(defaultCredSSPVersion, tokens, authInfo, pubKeyAuth, clientNonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeTSRequestVersion(version int, tokens [][]byte, authInfo, pubKeyAuth, clientNonce []byte) ([]byte, error) {
|
||||||
|
req := tsRequest{Version: version}
|
||||||
|
for _, token := range tokens {
|
||||||
|
req.NegoTokens = append(req.NegoTokens, negoToken{Data: token})
|
||||||
|
}
|
||||||
|
if len(authInfo) > 0 {
|
||||||
|
req.AuthInfo = authInfo
|
||||||
|
}
|
||||||
|
if len(pubKeyAuth) > 0 {
|
||||||
|
req.PubKeyAuth = pubKeyAuth
|
||||||
|
}
|
||||||
|
if len(clientNonce) > 0 {
|
||||||
|
req.ClientNonce = clientNonce
|
||||||
|
}
|
||||||
|
return asn1.Marshal(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTSRequest(data []byte) (*tsRequest, error) {
|
||||||
|
var req tsRequest
|
||||||
|
_, err := asn1.Unmarshal(data, &req)
|
||||||
|
return &req, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodePasswordCredentials(domain, user, password string) ([]byte, error) {
|
||||||
|
pass, err := asn1.Marshal(tsPasswordCreds{
|
||||||
|
DomainName: utf16le(domain),
|
||||||
|
UserName: utf16le(user),
|
||||||
|
Password: utf16le(password),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return asn1.Marshal(tsCredentials{CredType: 1, Credentials: pass})
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package credssp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options 控制 CredSSP 兼容策略,仅调试和特殊 Windows 版本需要覆盖 // Logan.Li
|
||||||
|
type Options struct {
|
||||||
|
Version int
|
||||||
|
PubKeyStyle string
|
||||||
|
ForceMIC bool
|
||||||
|
NoKeyExch bool
|
||||||
|
UseCBT bool
|
||||||
|
OmitVersion bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChallengeInfo 暴露 NTLM challenge 诊断信息,不包含凭据 // Logan.Li
|
||||||
|
type ChallengeInfo struct {
|
||||||
|
Version int
|
||||||
|
TargetName string
|
||||||
|
Flags uint32
|
||||||
|
TargetInfoBytes int
|
||||||
|
RequiresMIC bool
|
||||||
|
DomainName string
|
||||||
|
ComputerName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadChallengeInfo 只读取 CredSSP/NTLM challenge,不发送认证数据 // Logan.Li
|
||||||
|
func ReadChallengeInfo(conn *tls.Conn, version int) (ChallengeInfo, error) {
|
||||||
|
if version == 0 {
|
||||||
|
version = defaultCredSSPVersion
|
||||||
|
}
|
||||||
|
negotiate := buildNegotiate()
|
||||||
|
req, err := encodeTSRequestVersion(version, [][]byte{negotiate}, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ChallengeInfo{}, err
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(req); err != nil {
|
||||||
|
return ChallengeInfo{}, err
|
||||||
|
}
|
||||||
|
challengeReq, err := readTSRequest(conn)
|
||||||
|
if err != nil {
|
||||||
|
return ChallengeInfo{}, err
|
||||||
|
}
|
||||||
|
if len(challengeReq.NegoTokens) == 0 {
|
||||||
|
return ChallengeInfo{}, fmt.Errorf("CredSSP challenge 缺少 nego token")
|
||||||
|
}
|
||||||
|
challenge, err := parseChallenge(challengeReq.NegoTokens[0].Data)
|
||||||
|
if err != nil {
|
||||||
|
return ChallengeInfo{}, err
|
||||||
|
}
|
||||||
|
return ChallengeInfo{
|
||||||
|
Version: challengeReq.Version,
|
||||||
|
TargetName: challenge.TargetName,
|
||||||
|
Flags: challenge.Flags,
|
||||||
|
TargetInfoBytes: len(challenge.TargetInfo),
|
||||||
|
RequiresMIC: requiresMIC(challenge.TargetInfo),
|
||||||
|
DomainName: utf16leString(findAVPair(challenge.TargetInfo, msvAvNbDomainName)),
|
||||||
|
ComputerName: utf16leString(findAVPair(challenge.TargetInfo, msvAvNbComputerName)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate 在已升级 TLS 的 RDP 连接上执行最小 CredSSP/NTLMv2 认证 // Logan.Li
|
||||||
|
func Authenticate(conn *tls.Conn, domain, user, password string) error {
|
||||||
|
return AuthenticateWithOptions(conn, domain, user, password, Options{Version: defaultCredSSPVersion, PubKeyStyle: "binding"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticateWithOptions 使用指定 CredSSP 版本和 pubKeyAuth 算法执行认证 // Logan.Li
|
||||||
|
func AuthenticateWithOptions(conn *tls.Conn, domain, user, password string, opts Options) error {
|
||||||
|
if opts.Version == 0 {
|
||||||
|
opts.Version = defaultCredSSPVersion
|
||||||
|
}
|
||||||
|
if opts.PubKeyStyle == "" {
|
||||||
|
opts.PubKeyStyle = "binding"
|
||||||
|
}
|
||||||
|
domain, user = splitDomainUser(domain, user)
|
||||||
|
negotiate := buildNegotiate()
|
||||||
|
req, err := encodeTSRequestVersion(opts.Version, [][]byte{negotiate}, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
challengeReq, err := readTSRequest(conn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取 NTLM challenge 失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(challengeReq.NegoTokens) == 0 {
|
||||||
|
return fmt.Errorf("CredSSP challenge 缺少 nego token")
|
||||||
|
}
|
||||||
|
challenge, err := parseChallenge(challengeReq.NegoTokens[0].Data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ntlmOpts := ntlmOptions{
|
||||||
|
ForceMIC: opts.ForceMIC,
|
||||||
|
DisableKeyExch: opts.NoKeyExch,
|
||||||
|
OmitVersion: opts.OmitVersion,
|
||||||
|
}
|
||||||
|
if opts.UseCBT {
|
||||||
|
ntlmOpts.ChannelBindings = channelBindingsHash(conn)
|
||||||
|
}
|
||||||
|
auth, sec, err := buildAuthenticate(challenge, domain, user, password, "AIOPS", negotiate, ntlmOpts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pubKey, err := peerPublicKey(conn, opts.PubKeyStyle)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clientNonce := randomBytes(32)
|
||||||
|
pubKeyAuthPlain := buildPubKeyAuth(pubKey, clientNonce, opts.PubKeyStyle)
|
||||||
|
pubKeyAuth := sec.encrypt(pubKeyAuthPlain)
|
||||||
|
var nonce []byte
|
||||||
|
if opts.PubKeyStyle == "binding" {
|
||||||
|
nonce = clientNonce
|
||||||
|
}
|
||||||
|
req, err = encodeTSRequestVersion(opts.Version, [][]byte{auth}, nil, pubKeyAuth, nonce)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
serverReq, err := readTSRequest(conn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取服务端公钥校验失败(flags=0x%08x mic=%v targetInfo=%d): %w", challenge.Flags, requiresMIC(challenge.TargetInfo), len(challenge.TargetInfo), err)
|
||||||
|
}
|
||||||
|
if serverReq.ErrorCode != 0 {
|
||||||
|
return fmt.Errorf("CredSSP 返回错误码: %s", ntStatusMessage(serverReq.ErrorCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := encodePasswordCredentials(domain, user, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encryptedCreds := sec.encrypt(creds)
|
||||||
|
req, err = encodeTSRequestVersion(opts.Version, nil, encryptedCreds, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = conn.Write(req)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPubKeyAuth(pubKey, clientNonce []byte, style string) []byte {
|
||||||
|
switch style {
|
||||||
|
case "raw-plus-one":
|
||||||
|
out := append([]byte(nil), pubKey...)
|
||||||
|
if len(out) > 0 {
|
||||||
|
out[0]++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case "binding":
|
||||||
|
return credSSPBindingHash(clientNonce, pubKey)
|
||||||
|
default:
|
||||||
|
return pubKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ntStatusMessage(code int) string {
|
||||||
|
u := uint32(code)
|
||||||
|
switch u {
|
||||||
|
case 0xC0000064:
|
||||||
|
return "0xC0000064 用户不存在"
|
||||||
|
case 0xC000006A:
|
||||||
|
return "0xC000006A 密码错误"
|
||||||
|
case 0xC000006D:
|
||||||
|
return "0xC000006D 用户名或认证信息无效"
|
||||||
|
case 0xC000006E:
|
||||||
|
return "0xC000006E 账号登录限制"
|
||||||
|
case 0xC0000070:
|
||||||
|
return "0xC0000070 工作站限制"
|
||||||
|
case 0xC0000071:
|
||||||
|
return "0xC0000071 密码已过期"
|
||||||
|
case 0xC0000072:
|
||||||
|
return "0xC0000072 账号已禁用"
|
||||||
|
case 0xC000015B:
|
||||||
|
return "0xC000015B 当前用户未被授予远程登录权限"
|
||||||
|
case 0xC0000193:
|
||||||
|
return "0xC0000193 账号已过期"
|
||||||
|
case 0xC0000234:
|
||||||
|
return "0xC0000234 账号已锁定,请先在 Windows/域控侧解锁后再连接"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("0x%08X", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func credSSPBindingHash(clientNonce, pubKey []byte) []byte {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte("CredSSP Client-To-Server Binding Hash\x00"))
|
||||||
|
h.Write(clientNonce)
|
||||||
|
h.Write(pubKey)
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelBindingsHash(conn *tls.Conn) []byte {
|
||||||
|
state := conn.ConnectionState()
|
||||||
|
if len(state.PeerCertificates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cert := state.PeerCertificates[0]
|
||||||
|
alg := cert.SignatureAlgorithm.String()
|
||||||
|
var digest []byte
|
||||||
|
switch {
|
||||||
|
case strings.Contains(alg, "MD5"):
|
||||||
|
sum := md5.Sum(cert.Raw)
|
||||||
|
digest = sum[:]
|
||||||
|
case strings.Contains(alg, "SHA1"):
|
||||||
|
sum := sha1.Sum(cert.Raw)
|
||||||
|
digest = sum[:]
|
||||||
|
default:
|
||||||
|
sum := sha256.Sum256(cert.Raw)
|
||||||
|
digest = sum[:]
|
||||||
|
}
|
||||||
|
appData := append([]byte("tls-server-end-point:"), digest...)
|
||||||
|
gss := make([]byte, 16+len(appData))
|
||||||
|
copy(gss[16:], appData)
|
||||||
|
gss[12] = byte(len(appData))
|
||||||
|
gss[13] = byte(len(appData) >> 8)
|
||||||
|
gss[14] = byte(len(appData) >> 16)
|
||||||
|
gss[15] = byte(len(appData) >> 24)
|
||||||
|
sum := md5.Sum(gss)
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTSRequest(r io.Reader) (*tsRequest, error) {
|
||||||
|
der, err := readDER(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeTSRequest(der)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDER(r io.Reader) ([]byte, error) {
|
||||||
|
header := make([]byte, 2)
|
||||||
|
if _, err := io.ReadFull(r, header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if header[0] != 0x30 {
|
||||||
|
return nil, fmt.Errorf("unexpected DER sequence tag: 0x%x", header[0])
|
||||||
|
}
|
||||||
|
length := int(header[1])
|
||||||
|
lenBytes := 0
|
||||||
|
if header[1]&0x80 != 0 {
|
||||||
|
lenBytes = int(header[1] & 0x7f)
|
||||||
|
if lenBytes == 0 || lenBytes > 4 {
|
||||||
|
return nil, fmt.Errorf("invalid DER length bytes: %d", lenBytes)
|
||||||
|
}
|
||||||
|
raw := make([]byte, lenBytes)
|
||||||
|
if _, err := io.ReadFull(r, raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
length = 0
|
||||||
|
for _, b := range raw {
|
||||||
|
length = length<<8 | int(b)
|
||||||
|
}
|
||||||
|
header = append(header, raw...)
|
||||||
|
}
|
||||||
|
body := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(r, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(header, body...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerPublicKey(conn *tls.Conn, style string) ([]byte, error) {
|
||||||
|
state := conn.ConnectionState()
|
||||||
|
if len(state.PeerCertificates) == 0 {
|
||||||
|
return nil, fmt.Errorf("RDP TLS 证书为空")
|
||||||
|
}
|
||||||
|
cert := state.PeerCertificates[0]
|
||||||
|
if style == "rsa-compat" {
|
||||||
|
if pub, ok := cert.PublicKey.(*rsa.PublicKey); ok {
|
||||||
|
return asn1.Marshal(rsaPublicKeyCompat{N: pub.N, E: pub.E})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MS-CSSP v5/v6 绑定哈希必须使用 X.509 SubjectPublicKey,而非完整 SPKI // Logan.Li
|
||||||
|
return subjectPublicKey(cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func subjectPublicKey(cert *x509.Certificate) ([]byte, error) {
|
||||||
|
if pub, ok := cert.PublicKey.(*rsa.PublicKey); ok {
|
||||||
|
return asn1.Marshal(*pub)
|
||||||
|
}
|
||||||
|
var spki struct {
|
||||||
|
Algorithm pkix.AlgorithmIdentifier
|
||||||
|
PublicKey asn1.BitString
|
||||||
|
}
|
||||||
|
if _, err := asn1.Unmarshal(cert.RawSubjectPublicKeyInfo, &spki); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 TLS SubjectPublicKey 失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(spki.PublicKey.Bytes) == 0 {
|
||||||
|
return nil, fmt.Errorf("TLS SubjectPublicKey 为空")
|
||||||
|
}
|
||||||
|
return spki.PublicKey.Bytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rsaPublicKeyCompat struct {
|
||||||
|
N *big.Int `asn1:"explicit,tag:0"`
|
||||||
|
E int `asn1:"explicit,tag:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitDomainUser(domain, user string) (string, string) {
|
||||||
|
if strings.Contains(user, "\\") {
|
||||||
|
parts := strings.Split(user, "\\")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return parts[0], parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(user, "/") {
|
||||||
|
parts := strings.Split(user, "/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return parts[0], parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain, user
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package credssp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEncodeDecodeTSRequest(t *testing.T) {
|
||||||
|
nego := buildNegotiate()
|
||||||
|
req, err := encodeTSRequest([][]byte{nego}, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decoded, err := decodeTSRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded.Version != defaultCredSSPVersion {
|
||||||
|
t.Fatalf("version=%d", decoded.Version)
|
||||||
|
}
|
||||||
|
if len(decoded.NegoTokens) != 1 || string(decoded.NegoTokens[0].Data[:8]) != "NTLMSSP\x00" {
|
||||||
|
t.Fatalf("invalid nego token")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package credssp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rc4"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/md4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ntlmNegotiateUnicode uint32 = 0x00000001
|
||||||
|
ntlmRequestTarget uint32 = 0x00000004
|
||||||
|
ntlmNegotiateSign uint32 = 0x00000010
|
||||||
|
ntlmNegotiateSeal uint32 = 0x00000020
|
||||||
|
ntlmNegotiateNTLM uint32 = 0x00000200
|
||||||
|
ntlmNegotiateAlwaysSign uint32 = 0x00008000
|
||||||
|
ntlmNegotiateExtendedSessionSecurity uint32 = 0x00080000
|
||||||
|
ntlmNegotiateTargetInfo uint32 = 0x00800000
|
||||||
|
ntlmNegotiateVersion uint32 = 0x02000000
|
||||||
|
ntlmNegotiate128 uint32 = 0x20000000
|
||||||
|
ntlmNegotiateKeyExch uint32 = 0x40000000
|
||||||
|
ntlmNegotiate56 uint32 = 0x80000000
|
||||||
|
|
||||||
|
msvAvEOL uint16 = 0x0000
|
||||||
|
msvAvNbComputerName uint16 = 0x0001
|
||||||
|
msvAvNbDomainName uint16 = 0x0002
|
||||||
|
msvAvFlags uint16 = 0x0006
|
||||||
|
msvAvTimestamp uint16 = 0x0007
|
||||||
|
msvChannelBindings uint16 = 0x000A
|
||||||
|
)
|
||||||
|
|
||||||
|
const ntlmClientFlags = ntlmNegotiateUnicode |
|
||||||
|
ntlmRequestTarget |
|
||||||
|
ntlmNegotiateSign |
|
||||||
|
ntlmNegotiateSeal |
|
||||||
|
ntlmNegotiateNTLM |
|
||||||
|
ntlmNegotiateAlwaysSign |
|
||||||
|
ntlmNegotiateExtendedSessionSecurity |
|
||||||
|
ntlmNegotiateTargetInfo |
|
||||||
|
ntlmNegotiateVersion |
|
||||||
|
ntlmNegotiate128 |
|
||||||
|
ntlmNegotiateKeyExch
|
||||||
|
|
||||||
|
type ntlmChallenge struct {
|
||||||
|
Raw []byte
|
||||||
|
Flags uint32
|
||||||
|
TargetName string
|
||||||
|
ServerChallenge []byte
|
||||||
|
TargetInfo []byte
|
||||||
|
Timestamp []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type ntlmOptions struct {
|
||||||
|
ForceMIC bool
|
||||||
|
DisableKeyExch bool
|
||||||
|
ChannelBindings []byte
|
||||||
|
OmitVersion bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ntlmSecurity struct {
|
||||||
|
clientSigningKey []byte
|
||||||
|
serverSigningKey []byte
|
||||||
|
clientSealing *rc4.Cipher
|
||||||
|
serverSealing *rc4.Cipher
|
||||||
|
seq uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNegotiate() []byte {
|
||||||
|
buf := bytes.NewBuffer(nil)
|
||||||
|
buf.Write([]byte("NTLMSSP\x00"))
|
||||||
|
writeU32(buf, 1)
|
||||||
|
writeU32(buf, ntlmClientFlags)
|
||||||
|
writeSecBuffer(buf, 0, 0)
|
||||||
|
writeSecBuffer(buf, 0, 0)
|
||||||
|
buf.Write(ntlmVersion())
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChallenge(data []byte) (*ntlmChallenge, error) {
|
||||||
|
if len(data) < 48 || string(data[:8]) != "NTLMSSP\x00" || binary.LittleEndian.Uint32(data[8:12]) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid NTLM challenge")
|
||||||
|
}
|
||||||
|
targetNameLen := int(binary.LittleEndian.Uint16(data[12:14]))
|
||||||
|
targetNameOffset := int(binary.LittleEndian.Uint32(data[16:20]))
|
||||||
|
targetInfoLen := int(binary.LittleEndian.Uint16(data[40:42]))
|
||||||
|
targetInfoOffset := int(binary.LittleEndian.Uint32(data[44:48]))
|
||||||
|
if targetInfoOffset < 0 || targetInfoOffset+targetInfoLen > len(data) {
|
||||||
|
return nil, fmt.Errorf("invalid NTLM target info")
|
||||||
|
}
|
||||||
|
targetName := ""
|
||||||
|
if targetNameOffset >= 0 && targetNameOffset+targetNameLen <= len(data) {
|
||||||
|
targetName = utf16leString(data[targetNameOffset : targetNameOffset+targetNameLen])
|
||||||
|
}
|
||||||
|
targetInfo := data[targetInfoOffset : targetInfoOffset+targetInfoLen]
|
||||||
|
return &ntlmChallenge{
|
||||||
|
Raw: append([]byte(nil), data...),
|
||||||
|
Flags: binary.LittleEndian.Uint32(data[20:24]),
|
||||||
|
TargetName: targetName,
|
||||||
|
ServerChallenge: append([]byte(nil), data[24:32]...),
|
||||||
|
TargetInfo: append([]byte(nil), targetInfo...),
|
||||||
|
Timestamp: findAVPair(targetInfo, msvAvTimestamp),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAuthenticate(ch *ntlmChallenge, domain, user, password, workstation string, negotiate []byte, opts ntlmOptions) ([]byte, *ntlmSecurity, error) {
|
||||||
|
if domain == "" {
|
||||||
|
domain = ch.TargetName
|
||||||
|
}
|
||||||
|
if domain == "" {
|
||||||
|
domain = utf16leString(findAVPair(ch.TargetInfo, msvAvNbDomainName))
|
||||||
|
}
|
||||||
|
respKey := ntowfv2(password, user, domain)
|
||||||
|
clientChallenge := randomBytes(8)
|
||||||
|
timestamp := ch.Timestamp
|
||||||
|
if len(timestamp) == 0 {
|
||||||
|
timestamp = windowsFileTime(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
blob := bytes.NewBuffer(nil)
|
||||||
|
blob.Write([]byte{0x01, 0x01, 0, 0, 0, 0, 0, 0})
|
||||||
|
blob.Write(timestamp)
|
||||||
|
blob.Write(clientChallenge)
|
||||||
|
blob.Write([]byte{0, 0, 0, 0})
|
||||||
|
targetInfo := ch.TargetInfo
|
||||||
|
if len(opts.ChannelBindings) == 16 {
|
||||||
|
targetInfo = withChannelBindings(ch.TargetInfo, opts.ChannelBindings)
|
||||||
|
}
|
||||||
|
blob.Write(targetInfo)
|
||||||
|
blob.Write([]byte{0, 0, 0, 0})
|
||||||
|
|
||||||
|
ntProof := hmacMD5(respKey, append(append([]byte(nil), ch.ServerChallenge...), blob.Bytes()...))
|
||||||
|
ntResp := append(ntProof, blob.Bytes()...)
|
||||||
|
lmResp := append(hmacMD5(respKey, append(append([]byte(nil), ch.ServerChallenge...), clientChallenge...)), clientChallenge...)
|
||||||
|
sessionBaseKey := hmacMD5(respKey, ntProof)
|
||||||
|
exportedSessionKey := randomBytes(16)
|
||||||
|
encryptedSessionKey := rc4k(sessionBaseKey, exportedSessionKey)
|
||||||
|
|
||||||
|
domainBytes := utf16le(domain)
|
||||||
|
userBytes := utf16le(user)
|
||||||
|
workstationBytes := utf16le(workstation)
|
||||||
|
flags := ch.Flags & ntlmClientFlags
|
||||||
|
flags |= ntlmNegotiateUnicode | ntlmNegotiateExtendedSessionSecurity | ntlmNegotiateNTLM
|
||||||
|
if opts.DisableKeyExch {
|
||||||
|
flags &^= ntlmNegotiateKeyExch
|
||||||
|
exportedSessionKey = sessionBaseKey
|
||||||
|
encryptedSessionKey = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
includeMIC := opts.ForceMIC || requiresMIC(ch.TargetInfo)
|
||||||
|
baseLen := 72
|
||||||
|
if opts.OmitVersion {
|
||||||
|
baseLen = 64
|
||||||
|
}
|
||||||
|
if includeMIC {
|
||||||
|
baseLen += 16
|
||||||
|
}
|
||||||
|
payload := bytes.NewBuffer(nil)
|
||||||
|
offset := uint32(baseLen)
|
||||||
|
lmOffset := offset
|
||||||
|
payload.Write(lmResp)
|
||||||
|
offset += uint32(len(lmResp))
|
||||||
|
ntOffset := offset
|
||||||
|
payload.Write(ntResp)
|
||||||
|
offset += uint32(len(ntResp))
|
||||||
|
domainOffset := offset
|
||||||
|
payload.Write(domainBytes)
|
||||||
|
offset += uint32(len(domainBytes))
|
||||||
|
userOffset := offset
|
||||||
|
payload.Write(userBytes)
|
||||||
|
offset += uint32(len(userBytes))
|
||||||
|
workstationOffset := offset
|
||||||
|
payload.Write(workstationBytes)
|
||||||
|
offset += uint32(len(workstationBytes))
|
||||||
|
sessionOffset := offset
|
||||||
|
if len(encryptedSessionKey) > 0 {
|
||||||
|
payload.Write(encryptedSessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := bytes.NewBuffer(nil)
|
||||||
|
msg.Write([]byte("NTLMSSP\x00"))
|
||||||
|
writeU32(msg, 3)
|
||||||
|
writeSecBuffer(msg, uint16(len(lmResp)), lmOffset)
|
||||||
|
writeSecBuffer(msg, uint16(len(ntResp)), ntOffset)
|
||||||
|
writeSecBuffer(msg, uint16(len(domainBytes)), domainOffset)
|
||||||
|
writeSecBuffer(msg, uint16(len(userBytes)), userOffset)
|
||||||
|
writeSecBuffer(msg, uint16(len(workstationBytes)), workstationOffset)
|
||||||
|
writeSecBuffer(msg, uint16(len(encryptedSessionKey)), sessionOffset)
|
||||||
|
writeU32(msg, flags)
|
||||||
|
if !opts.OmitVersion {
|
||||||
|
msg.Write(ntlmVersion())
|
||||||
|
}
|
||||||
|
micPos := -1
|
||||||
|
if includeMIC {
|
||||||
|
micPos = msg.Len()
|
||||||
|
msg.Write(make([]byte, 16))
|
||||||
|
}
|
||||||
|
msg.Write(payload.Bytes())
|
||||||
|
|
||||||
|
if includeMIC {
|
||||||
|
micInput := bytes.NewBuffer(nil)
|
||||||
|
micInput.Write(negotiate)
|
||||||
|
micInput.Write(ch.Raw)
|
||||||
|
micInput.Write(msg.Bytes())
|
||||||
|
mic := hmacMD5(exportedSessionKey, micInput.Bytes())
|
||||||
|
copy(msg.Bytes()[micPos:micPos+16], mic)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSeal, _ := rc4.NewCipher(md5sum(append(exportedSessionKey, []byte("session key to client-to-server sealing key magic constant\x00")...)))
|
||||||
|
serverSeal, _ := rc4.NewCipher(md5sum(append(exportedSessionKey, []byte("session key to server-to-client sealing key magic constant\x00")...)))
|
||||||
|
sec := &ntlmSecurity{
|
||||||
|
clientSigningKey: md5sum(append(exportedSessionKey, []byte("session key to client-to-server signing key magic constant\x00")...)),
|
||||||
|
serverSigningKey: md5sum(append(exportedSessionKey, []byte("session key to server-to-client signing key magic constant\x00")...)),
|
||||||
|
clientSealing: clientSeal,
|
||||||
|
serverSealing: serverSeal,
|
||||||
|
}
|
||||||
|
return msg.Bytes(), sec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ntlmSecurity) encrypt(data []byte) []byte {
|
||||||
|
sealed := make([]byte, len(data))
|
||||||
|
s.clientSealing.XORKeyStream(sealed, data)
|
||||||
|
macInput := bytes.NewBuffer(nil)
|
||||||
|
writeU32(macInput, s.seq)
|
||||||
|
macInput.Write(data)
|
||||||
|
checksum := hmacMD5(s.clientSigningKey, macInput.Bytes())[:8]
|
||||||
|
encryptedChecksum := make([]byte, 8)
|
||||||
|
s.clientSealing.XORKeyStream(encryptedChecksum, checksum)
|
||||||
|
out := bytes.NewBuffer(nil)
|
||||||
|
writeU32(out, 1)
|
||||||
|
out.Write(encryptedChecksum)
|
||||||
|
writeU32(out, s.seq)
|
||||||
|
out.Write(sealed)
|
||||||
|
s.seq++
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ntowfv2(password, user, domain string) []byte {
|
||||||
|
h := md4.New()
|
||||||
|
h.Write(utf16le(password))
|
||||||
|
return hmacMD5(h.Sum(nil), utf16le(strings.ToUpper(user)+domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hmacMD5(key, data []byte) []byte {
|
||||||
|
h := hmac.New(md5.New, key)
|
||||||
|
h.Write(data)
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func md5sum(data []byte) []byte {
|
||||||
|
sum := md5.Sum(data)
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func rc4k(key, data []byte) []byte {
|
||||||
|
c, _ := rc4.NewCipher(key)
|
||||||
|
out := make([]byte, len(data))
|
||||||
|
c.XORKeyStream(out, data)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomBytes(n int) []byte {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
_, _ = rand.Read(buf)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsFileTime(t time.Time) []byte {
|
||||||
|
v := uint64(t.UnixNano()/100) + 116444736000000000
|
||||||
|
out := make([]byte, 8)
|
||||||
|
binary.LittleEndian.PutUint64(out, v)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAVPair(data []byte, id uint16) []byte {
|
||||||
|
for len(data) >= 4 {
|
||||||
|
avID := binary.LittleEndian.Uint16(data[0:2])
|
||||||
|
avLen := int(binary.LittleEndian.Uint16(data[2:4]))
|
||||||
|
data = data[4:]
|
||||||
|
if avLen > len(data) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if avID == msvAvEOL {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if avID == id {
|
||||||
|
return append([]byte(nil), data[:avLen]...)
|
||||||
|
}
|
||||||
|
data = data[avLen:]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiresMIC(targetInfo []byte) bool {
|
||||||
|
flags := findAVPair(targetInfo, msvAvFlags)
|
||||||
|
if len(flags) < 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return binary.LittleEndian.Uint32(flags)&0x00000002 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func withChannelBindings(targetInfo, hash []byte) []byte {
|
||||||
|
out := bytes.NewBuffer(nil)
|
||||||
|
inserted := false
|
||||||
|
for len(targetInfo) >= 4 {
|
||||||
|
avID := binary.LittleEndian.Uint16(targetInfo[0:2])
|
||||||
|
avLen := int(binary.LittleEndian.Uint16(targetInfo[2:4]))
|
||||||
|
if 4+avLen > len(targetInfo) {
|
||||||
|
return targetInfo
|
||||||
|
}
|
||||||
|
if avID == msvAvEOL {
|
||||||
|
if !inserted {
|
||||||
|
writeU16(out, msvChannelBindings)
|
||||||
|
writeU16(out, 16)
|
||||||
|
out.Write(hash[:16])
|
||||||
|
}
|
||||||
|
out.Write(targetInfo[:4])
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
if avID == msvChannelBindings {
|
||||||
|
writeU16(out, msvChannelBindings)
|
||||||
|
writeU16(out, 16)
|
||||||
|
out.Write(hash[:16])
|
||||||
|
inserted = true
|
||||||
|
} else {
|
||||||
|
out.Write(targetInfo[:4+avLen])
|
||||||
|
}
|
||||||
|
targetInfo = targetInfo[4+avLen:]
|
||||||
|
}
|
||||||
|
if !inserted {
|
||||||
|
writeU16(out, msvChannelBindings)
|
||||||
|
writeU16(out, 16)
|
||||||
|
out.Write(hash[:16])
|
||||||
|
}
|
||||||
|
out.Write([]byte{0, 0, 0, 0})
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSecBuffer(buf *bytes.Buffer, length uint16, offset uint32) {
|
||||||
|
writeU16(buf, length)
|
||||||
|
writeU16(buf, length)
|
||||||
|
writeU32(buf, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeU16(buf *bytes.Buffer, v uint16) {
|
||||||
|
_ = binary.Write(buf, binary.LittleEndian, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeU32(buf *bytes.Buffer, v uint32) {
|
||||||
|
_ = binary.Write(buf, binary.LittleEndian, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ntlmVersion() []byte {
|
||||||
|
out := []byte{10, 0, 0, 0, 0, 0, 0, 15}
|
||||||
|
binary.LittleEndian.PutUint16(out[2:4], 19045)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16le(s string) []byte {
|
||||||
|
out := bytes.NewBuffer(nil)
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 0xffff {
|
||||||
|
r = '?'
|
||||||
|
}
|
||||||
|
writeU16(out, uint16(r))
|
||||||
|
}
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16leString(data []byte) string {
|
||||||
|
runes := make([]rune, 0, len(data)/2)
|
||||||
|
for i := 0; i+1 < len(data); i += 2 {
|
||||||
|
v := binary.LittleEndian.Uint16(data[i : i+2])
|
||||||
|
if v == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runes = append(runes, rune(v))
|
||||||
|
}
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package credssp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubjectPublicKeyDiffersFromPKIX(t *testing.T) {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "rdp-test"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
}, &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "rdp-test"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
}, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(certDER)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
subjectKey, err := subjectPublicKey(cert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pkixKey, err := x509.MarshalPKIXPublicKey(cert.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(subjectKey) == string(pkixKey) {
|
||||||
|
t.Fatal("SubjectPublicKey 不应等于完整 SPKI")
|
||||||
|
}
|
||||||
|
rsaDER, err := asn1.Marshal(*cert.PublicKey.(*rsa.PublicKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(subjectKey) != string(rsaDER) {
|
||||||
|
t.Fatalf("SubjectPublicKey 应与 RSA PKCS#1 DER 一致,len=%d/%d", len(subjectKey), len(rsaDER))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"image"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bitmap 表示协议层解码后的 BGRA/RGB565 小块,display 只负责合成和输出 // Logan.Li
|
||||||
|
type Bitmap struct {
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
BitsPerPixel int
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame 表示一次合成后的脏区域输出 // Logan.Li
|
||||||
|
type Frame struct {
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Image string
|
||||||
|
RGBA []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Framebuffer 借鉴 Guacamole/bring 的 display/layer 思路,在后端维护完整画布 // Logan.Li
|
||||||
|
type Framebuffer struct {
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
img *image.RGBA
|
||||||
|
dirty image.Rectangle
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFramebuffer 创建固定尺寸远端画布,所有 RDP 更新先合成到这里 // Logan.Li
|
||||||
|
func NewFramebuffer(width, height int) *Framebuffer {
|
||||||
|
if width <= 0 {
|
||||||
|
width = 1280
|
||||||
|
}
|
||||||
|
if height <= 0 {
|
||||||
|
height = 800
|
||||||
|
}
|
||||||
|
return &Framebuffer{
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
img: image.NewRGBA(image.Rect(0, 0, width, height)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw 合成一个 bitmap 小块,并扩大脏矩形 // Logan.Li
|
||||||
|
func (f *Framebuffer) Draw(b Bitmap) {
|
||||||
|
if b.Width <= 0 || b.Height <= 0 || len(b.Data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dstRect := image.Rect(b.X, b.Y, b.X+b.Width, b.Y+b.Height).Intersect(f.img.Bounds())
|
||||||
|
if dstRect.Empty() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
src := bitmapToRGBA(b)
|
||||||
|
srcPoint := image.Pt(dstRect.Min.X-b.X, dstRect.Min.Y-b.Y)
|
||||||
|
draw.Draw(f.img, dstRect, src, srcPoint, draw.Src)
|
||||||
|
if f.dirty.Empty() {
|
||||||
|
f.dirty = dstRect
|
||||||
|
} else {
|
||||||
|
f.dirty = f.dirty.Union(dstRect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush 输出当前脏区域 PNG,并清空脏矩形 // Logan.Li
|
||||||
|
func (f *Framebuffer) Flush() (Frame, bool) {
|
||||||
|
frame, ok := f.FlushRGBA()
|
||||||
|
if !ok {
|
||||||
|
return Frame{}, false
|
||||||
|
}
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height))
|
||||||
|
copy(img.Pix, frame.RGBA)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return Frame{}, false
|
||||||
|
}
|
||||||
|
frame.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
return frame, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushRGBA 输出当前脏区域原始 RGBA,避免实时 RDP 路径重复 PNG/base64 编解码。 // Logan.Li
|
||||||
|
func (f *Framebuffer) FlushRGBA() (Frame, bool) {
|
||||||
|
if f.dirty.Empty() {
|
||||||
|
return Frame{}, false
|
||||||
|
}
|
||||||
|
rect := f.dirty.Intersect(f.img.Bounds())
|
||||||
|
f.dirty = image.Rectangle{}
|
||||||
|
if rect.Empty() {
|
||||||
|
return Frame{}, false
|
||||||
|
}
|
||||||
|
crop := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
|
||||||
|
draw.Draw(crop, crop.Bounds(), f.img, rect.Min, draw.Src)
|
||||||
|
return Frame{
|
||||||
|
X: rect.Min.X,
|
||||||
|
Y: rect.Min.Y,
|
||||||
|
Width: rect.Dx(),
|
||||||
|
Height: rect.Dy(),
|
||||||
|
RGBA: append([]byte(nil), crop.Pix...),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func bitmapToRGBA(b Bitmap) *image.RGBA {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, b.Width, b.Height))
|
||||||
|
pix := img.Pix
|
||||||
|
switch b.BitsPerPixel {
|
||||||
|
case 1, 2:
|
||||||
|
n := b.Width * b.Height
|
||||||
|
for i := 0; i < n && i*2+1 < len(b.Data); i++ {
|
||||||
|
// grdp 的 RLE 输出 16bpp 使用大端 RGB565,按小端会导致蓝紫/绿色偏色。 // Logan.Li
|
||||||
|
c := binary.BigEndian.Uint16(b.Data[i*2 : i*2+2])
|
||||||
|
r := uint8((c >> 11) & 0x1F)
|
||||||
|
g := uint8((c >> 5) & 0x3F)
|
||||||
|
bl := uint8(c & 0x1F)
|
||||||
|
off := i * 4
|
||||||
|
pix[off] = (r << 3) | (r >> 2)
|
||||||
|
pix[off+1] = (g << 2) | (g >> 4)
|
||||||
|
pix[off+2] = (bl << 3) | (bl >> 2)
|
||||||
|
pix[off+3] = 0xFF
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
stride := b.BitsPerPixel
|
||||||
|
n := b.Width * b.Height
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
s := i * stride
|
||||||
|
if s+2 >= len(b.Data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
off := i * 4
|
||||||
|
pix[off] = b.Data[s+2]
|
||||||
|
pix[off+1] = b.Data[s+1]
|
||||||
|
pix[off+2] = b.Data[s]
|
||||||
|
pix[off+3] = 0xFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 是后端 RDP 渲染引擎的连接配置,凭据只在服务端使用 // Logan.Li
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Domain string
|
||||||
|
Security string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
MCSMode string
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientEvent 是浏览器发回后端的输入事件 // Logan.Li
|
||||||
|
type ClientEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
X int `json:"x,omitempty"`
|
||||||
|
Y int `json:"y,omitempty"`
|
||||||
|
Button int `json:"button,omitempty"`
|
||||||
|
DeltaY int `json:"delta_y,omitempty"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Down bool `json:"down,omitempty"`
|
||||||
|
Width int `json:"width,omitempty"`
|
||||||
|
Height int `json:"height,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerFrame 是后端推给浏览器 Canvas 的统一帧协议 // Logan.Li
|
||||||
|
type ServerFrame struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
X int `json:"x,omitempty"`
|
||||||
|
Y int `json:"y,omitempty"`
|
||||||
|
Width int `json:"width,omitempty"`
|
||||||
|
Height int `json:"height,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
RGBA []byte `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameSink 接收 RDP 引擎输出,控制器负责写入 WebSocket // Logan.Li
|
||||||
|
type FrameSink interface {
|
||||||
|
SendFrame(ServerFrame) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine 隔离自研 RDP 协议实现,避免控制器关心握手和图形帧细节 // Logan.Li
|
||||||
|
type Engine interface {
|
||||||
|
Run(ctx context.Context, cfg Config, sink FrameSink, events <-chan ClientEvent) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandshakeInfo 记录轻量 RDP 第一阶段握手结果,用于前端诊断 // Logan.Li
|
||||||
|
type HandshakeInfo struct {
|
||||||
|
Address string
|
||||||
|
SelectedProtocol string
|
||||||
|
Duration time.Duration
|
||||||
|
TLSCipher string
|
||||||
|
CertSubject string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryRecorder 保存图形帧摘要,后续可替换为 PNG/JPEG 序列录屏 // Logan.Li
|
||||||
|
type MemoryRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []ServerFrame
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoryRecorder 创建内存帧记录器,限制录屏摘要常驻内存大小 // Logan.Li
|
||||||
|
func NewMemoryRecorder(limit int) *MemoryRecorder {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
return &MemoryRecorder{limit: limit}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append 记录一帧 RDP 输出,超过上限时丢弃最早帧 // Logan.Li
|
||||||
|
func (r *MemoryRecorder) Append(frame ServerFrame) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
// 实时帧只用于 WebSocket 推送,审计内存索引保留元数据避免拖动窗口时触发大对象 GC。 // Logan.Li
|
||||||
|
frame.RGBA = nil
|
||||||
|
if frame.Type == "bitmap" {
|
||||||
|
frame.Image = ""
|
||||||
|
}
|
||||||
|
if len(r.events) >= r.limit {
|
||||||
|
copy(r.events, r.events[1:])
|
||||||
|
r.events[len(r.events)-1] = frame
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.events = append(r.events, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendSnapshot 记录节流后的图形快照,用于 RDP 历史回放。 // Logan.Li
|
||||||
|
func (r *MemoryRecorder) AppendSnapshot(frame ServerFrame) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
frame.RGBA = nil
|
||||||
|
if len(r.events) >= r.limit {
|
||||||
|
copy(r.events, r.events[1:])
|
||||||
|
r.events[len(r.events)-1] = frame
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.events = append(r.events, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON 输出前端回放需要的轻量帧序列 // Logan.Li
|
||||||
|
func (r *MemoryRecorder) JSON() string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
b, _ := json.Marshal(struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Frames []ServerFrame `json:"frames"`
|
||||||
|
}{Version: 1, Time: time.Now(), Frames: r.events})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ezops/pkg/rdp/domain"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 是后端 RDP 渲染引擎的连接配置,根包保留别名兼容控制器 // Logan.Li
|
||||||
|
type Config = domain.Config
|
||||||
|
|
||||||
|
// ClientEvent 是浏览器发回后端的输入事件,根包保留别名兼容控制器 // Logan.Li
|
||||||
|
type ClientEvent = domain.ClientEvent
|
||||||
|
|
||||||
|
// ServerFrame 是后端推给浏览器 Canvas 的统一帧协议,根包保留别名兼容控制器 // Logan.Li
|
||||||
|
type ServerFrame = domain.ServerFrame
|
||||||
|
|
||||||
|
// FrameSink 接收 RDP 引擎输出,根包保留别名兼容控制器 // Logan.Li
|
||||||
|
type FrameSink = domain.FrameSink
|
||||||
|
|
||||||
|
// Engine 隔离自研 RDP 协议实现,根包保留别名兼容控制器 // Logan.Li
|
||||||
|
type Engine = domain.Engine
|
||||||
|
|
||||||
|
// HandshakeInfo 记录轻量 RDP 第一阶段握手结果,根包保留别名兼容诊断入口 // Logan.Li
|
||||||
|
type HandshakeInfo = domain.HandshakeInfo
|
||||||
|
|
||||||
|
// MemoryRecorder 保存图形帧摘要,根包保留别名兼容审计回放 // Logan.Li
|
||||||
|
type MemoryRecorder = domain.MemoryRecorder
|
||||||
|
|
||||||
|
const EngineRDP = "rdp"
|
||||||
|
|
||||||
|
// SelectedEngineName 返回当前 RDP 引擎名称,固定使用 AIOps 自研 RDP 引擎。 // Logan.Li
|
||||||
|
func SelectedEngineName() string {
|
||||||
|
return EngineRDP
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine 创建自研 RDP 图形引擎,避免依赖第三方协议栈。 // Logan.Li
|
||||||
|
func NewEngine() Engine {
|
||||||
|
name := SelectedEngineName()
|
||||||
|
log.Printf("[RDP] selected engine=%s", name)
|
||||||
|
return &SessionEngine{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoryRecorder 创建内存帧记录器,根包保留构造函数兼容审计回放 // Logan.Li
|
||||||
|
func NewMemoryRecorder(limit int) *MemoryRecorder {
|
||||||
|
return domain.NewMemoryRecorder(limit)
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"ezops/pkg/rdp/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fastPathOutputCompressionUsed = 0xC0
|
||||||
|
fastPathFragmentSingle = 0x00
|
||||||
|
fastPathFragmentLast = 0x10
|
||||||
|
fastPathFragmentFirst = 0x20
|
||||||
|
fastPathFragmentNext = 0x30
|
||||||
|
)
|
||||||
|
|
||||||
|
// FastPathReceiver 解析服务端 Fast-Path 更新并产出位图块 // Logan.Li
|
||||||
|
type FastPathReceiver struct {
|
||||||
|
mppc *MppcDecompressor
|
||||||
|
fragments bytes.Buffer
|
||||||
|
frameIDs []uint32
|
||||||
|
updateStats map[byte]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFastPathReceiver() *FastPathReceiver {
|
||||||
|
return &FastPathReceiver{mppc: NewMppcDecompressor(), updateStats: make(map[byte]int)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FastPathReceiver) Handle(secFlag byte, payload []byte) ([]BitmapRect, error) {
|
||||||
|
_ = secFlag
|
||||||
|
r := bytes.NewReader(payload)
|
||||||
|
var rects []BitmapRect
|
||||||
|
for r.Len() > 0 {
|
||||||
|
updateHeader, err := readU8(r)
|
||||||
|
if err != nil {
|
||||||
|
return rects, err
|
||||||
|
}
|
||||||
|
updateCode := updateHeader & 0x0f
|
||||||
|
fragmentation := updateHeader & 0x30
|
||||||
|
compression := updateHeader & 0xC0
|
||||||
|
f.updateStats[updateCode]++
|
||||||
|
|
||||||
|
var compressionFlags byte
|
||||||
|
if compression == fastPathOutputCompressionUsed {
|
||||||
|
compressionFlags, err = readU8(r)
|
||||||
|
if err != nil {
|
||||||
|
return rects, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return rects, err
|
||||||
|
}
|
||||||
|
if f.updateStats[updateCode] <= 8 || f.updateStats[updateCode]%100 == 0 {
|
||||||
|
trace.Logf("fastpath update code=%s(0x%x) count=%d size=%d frag=0x%02x compression=0x%02x flags=0x%02x", fastPathUpdateName(updateCode), updateCode, f.updateStats[updateCode], size, fragmentation, compression, compressionFlags)
|
||||||
|
}
|
||||||
|
block := make([]byte, int(size))
|
||||||
|
if _, err = r.Read(block); err != nil {
|
||||||
|
return rects, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if fragmentation != fastPathFragmentSingle {
|
||||||
|
if fragmentation == fastPathFragmentFirst {
|
||||||
|
f.fragments.Reset()
|
||||||
|
}
|
||||||
|
f.fragments.Write(block)
|
||||||
|
if fragmentation != fastPathFragmentLast {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
block = append([]byte(nil), f.fragments.Bytes()...)
|
||||||
|
}
|
||||||
|
if compressionFlags != 0 {
|
||||||
|
decompressed, err := f.mppc.Decompress(compressionFlags, block)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
block = decompressed
|
||||||
|
}
|
||||||
|
|
||||||
|
switch updateCode {
|
||||||
|
case fastPathUpdateBitmap:
|
||||||
|
bs, err := parseFastPathBitmap(block)
|
||||||
|
if err != nil {
|
||||||
|
return rects, err
|
||||||
|
}
|
||||||
|
rects = append(rects, bs...)
|
||||||
|
case fastPathUpdateSurfaceCmds:
|
||||||
|
bs, frameIDs, err := parseSurfaceCommands(block)
|
||||||
|
if err != nil {
|
||||||
|
return rects, err
|
||||||
|
}
|
||||||
|
f.frameIDs = append(f.frameIDs, frameIDs...)
|
||||||
|
rects = append(rects, bs...)
|
||||||
|
default:
|
||||||
|
trace.Logf("fastpath skip update code=%s(0x%x) size=%d", fastPathUpdateName(updateCode), updateCode, len(block))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fastPathUpdateName(code byte) string {
|
||||||
|
switch code {
|
||||||
|
case 0x0:
|
||||||
|
return "orders"
|
||||||
|
case fastPathUpdateBitmap:
|
||||||
|
return "bitmap"
|
||||||
|
case 0x2:
|
||||||
|
return "palette"
|
||||||
|
case 0x3:
|
||||||
|
return "synchronize"
|
||||||
|
case fastPathUpdateSurfaceCmds:
|
||||||
|
return "surface"
|
||||||
|
case 0x5:
|
||||||
|
return "ptr_null"
|
||||||
|
case 0x6:
|
||||||
|
return "ptr_default"
|
||||||
|
case 0x8:
|
||||||
|
return "ptr_position"
|
||||||
|
case 0x9:
|
||||||
|
return "color"
|
||||||
|
case 0x0a:
|
||||||
|
return "ptr_cached"
|
||||||
|
case 0x0b:
|
||||||
|
return "pointer"
|
||||||
|
case 0x0c:
|
||||||
|
return "large_pointer"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeFrameIDs 取出需要确认的 Surface frame,避免服务端因队列满停止推帧 // Logan.Li
|
||||||
|
func (f *FastPathReceiver) TakeFrameIDs() []uint32 {
|
||||||
|
if len(f.frameIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := append([]uint32(nil), f.frameIDs...)
|
||||||
|
f.frameIDs = f.frameIDs[:0]
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FastPathReceiver) HandleSlowPath(payload []byte) ([]BitmapRect, error) {
|
||||||
|
ind, err := parseMCSDataIndication(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(ind.payload) < 6 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return parseSlowPathBitmap(ind.payload[4:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSurfaceCommands(data []byte) ([]BitmapRect, []uint32, error) {
|
||||||
|
r := bytes.NewReader(data)
|
||||||
|
var out []BitmapRect
|
||||||
|
var frameIDs []uint32
|
||||||
|
for r.Len() >= 2 {
|
||||||
|
cmdType, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return out, frameIDs, err
|
||||||
|
}
|
||||||
|
switch cmdType {
|
||||||
|
case 0x0001, 0x0006:
|
||||||
|
wb, err := readSurfaceBits(r)
|
||||||
|
if err != nil {
|
||||||
|
return out, frameIDs, err
|
||||||
|
}
|
||||||
|
if rect, ok := decodeWireBitmap(wb); ok {
|
||||||
|
out = append(out, rect)
|
||||||
|
}
|
||||||
|
case 0x0004:
|
||||||
|
frameAction, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return out, frameIDs, err
|
||||||
|
}
|
||||||
|
frameID, err := readLE32(r)
|
||||||
|
if err != nil {
|
||||||
|
return out, frameIDs, err
|
||||||
|
}
|
||||||
|
trace.Logf("surface frame marker action=%d frameID=%d", frameAction, frameID)
|
||||||
|
if frameAction == 0x0001 {
|
||||||
|
frameIDs = append(frameIDs, frameID)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return out, frameIDs, fmt.Errorf("未知 SurfaceCmd: 0x%04x", cmdType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, frameIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSurfaceBits(r *bytes.Reader) (wireBitmap, error) {
|
||||||
|
destLeft, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
destTop, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
destRight, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
destBottom, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
bpp, err := readU8(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
flags, err := readU8(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
if _, err = readU8(r); err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
codecID, err := readU8(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
width, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
height, err := readLE16(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
dataLen, err := readLE32(r)
|
||||||
|
if err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
if flags&0x01 != 0 {
|
||||||
|
if dataLen < 24 {
|
||||||
|
return wireBitmap{}, fmt.Errorf("SurfaceBits 扩展头长度非法: %d", dataLen)
|
||||||
|
}
|
||||||
|
if _, err = r.Seek(24, 1); err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
dataLen -= 24
|
||||||
|
}
|
||||||
|
data := make([]byte, int(dataLen))
|
||||||
|
if _, err = r.Read(data); err != nil {
|
||||||
|
return wireBitmap{}, err
|
||||||
|
}
|
||||||
|
if codecID != 0 {
|
||||||
|
trace.Logf("surface skip unsupported codec=%d rect=%dx%d@%d,%d data=%d flags=0x%02x", codecID, width, height, destLeft, destTop, dataLen, flags)
|
||||||
|
return wireBitmap{}, nil
|
||||||
|
}
|
||||||
|
trace.Logf("surface bits codec=%d rect=%dx%d@%d,%d data=%d flags=0x%02x", codecID, width, height, destLeft, destTop, dataLen, flags)
|
||||||
|
return wireBitmap{
|
||||||
|
DestLeft: destLeft, DestTop: destTop, DestRight: destRight, DestBottom: destBottom,
|
||||||
|
Width: width, Height: height, BitsPerPixel: uint16(bpp), Flags: uint16(flags) | bitmapNoProcessing,
|
||||||
|
BitmapDataStream: data,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readU8(r *bytes.Reader) (byte, error) {
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf16"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fastPathInputEventScancode = 0
|
||||||
|
fastPathInputEventMouse = 1
|
||||||
|
fastPathInputEventUnicode = 4
|
||||||
|
|
||||||
|
fastPathInputKBRelease = 0x01
|
||||||
|
fastPathInputKBExtended = 0x02
|
||||||
|
fastPathInputKBExtended1 = 0x04
|
||||||
|
|
||||||
|
ptrFlagsMove = 0x0800
|
||||||
|
ptrFlagsDown = 0x8000
|
||||||
|
ptrFlagsBtn1 = 0x1000
|
||||||
|
ptrFlagsBtn2 = 0x2000
|
||||||
|
ptrFlagsBtn3 = 0x4000
|
||||||
|
ptrFlagsWheel = 0x0200
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputSender 通过 Fast-Path Input 向远端发送键鼠事件 // Logan.Li
|
||||||
|
type InputSender struct {
|
||||||
|
w io.Writer
|
||||||
|
mu *sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInputSender(w io.Writer) *InputSender {
|
||||||
|
return &InputSender{w: w}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInputSenderWithLock(w io.Writer, mu *sync.Mutex) *InputSender {
|
||||||
|
return &InputSender{w: w, mu: mu}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InputSender) Send(events []byte) error {
|
||||||
|
if s.mu != nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
}
|
||||||
|
return WriteFastPathInput(s.w, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InputSender) Key(scancode int, down bool) error {
|
||||||
|
flags := byte(0)
|
||||||
|
if !down {
|
||||||
|
flags |= fastPathInputKBRelease
|
||||||
|
}
|
||||||
|
code := uint16(scancode)
|
||||||
|
if scancode&0xFF00 == 0xE000 {
|
||||||
|
flags |= fastPathInputKBExtended
|
||||||
|
code = uint16(scancode & 0xFF)
|
||||||
|
}
|
||||||
|
buf := []byte{byte(fastPathInputEventScancode<<5) | flags}
|
||||||
|
buf = append(buf, byte(code))
|
||||||
|
return s.Send(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InputSender) MouseMove(x, y int) error {
|
||||||
|
return s.mouseEvent(ptrFlagsMove, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InputSender) MouseButton(x, y, button int, down bool) error {
|
||||||
|
flags := ptrFlagsDown
|
||||||
|
// 浏览器 MouseEvent.button 使用 0/1/2 表示左/中/右键,RDP BTN2 才是右键。 // Logan.Li
|
||||||
|
switch button {
|
||||||
|
case 2:
|
||||||
|
flags |= ptrFlagsBtn2
|
||||||
|
case 1:
|
||||||
|
flags |= ptrFlagsBtn3
|
||||||
|
default:
|
||||||
|
flags |= ptrFlagsBtn1
|
||||||
|
}
|
||||||
|
if !down {
|
||||||
|
flags &^= ptrFlagsDown
|
||||||
|
}
|
||||||
|
return s.mouseEvent(flags, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InputSender) MouseWheel(x, y, deltaY int) error {
|
||||||
|
scroll := -deltaY * 120 / 100
|
||||||
|
if scroll == 0 && deltaY != 0 {
|
||||||
|
if deltaY > 0 {
|
||||||
|
scroll = -120
|
||||||
|
} else {
|
||||||
|
scroll = 120
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flags := uint16(ptrFlagsWheel | 0x0001)
|
||||||
|
if scroll < 0 {
|
||||||
|
flags |= 0x0100
|
||||||
|
scroll = -scroll
|
||||||
|
}
|
||||||
|
buf := []byte{byte(fastPathInputEventMouse << 5)}
|
||||||
|
buf = append(buf, byte(flags), byte(flags>>8))
|
||||||
|
buf = append(buf, byte(x), byte(x>>8), byte(y), byte(y>>8))
|
||||||
|
buf = append(buf, byte(scroll), byte(scroll>>8))
|
||||||
|
return s.Send(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InputSender) mouseEvent(flags int, x, y int) error {
|
||||||
|
buf := []byte{byte(fastPathInputEventMouse << 5)}
|
||||||
|
buf = append(buf, byte(flags), byte(flags>>8))
|
||||||
|
buf = append(buf, byte(x), byte(x>>8), byte(y), byte(y>>8))
|
||||||
|
return s.Send(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InputSender) TypeText(text string) error {
|
||||||
|
for _, r := range utf16.Encode([]rune(text)) {
|
||||||
|
if err := s.unicodeKey(uint16(r), true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.unicodeKey(uint16(r), false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InputSender) unicodeKey(code uint16, down bool) error {
|
||||||
|
flags := byte(0)
|
||||||
|
if !down {
|
||||||
|
flags |= fastPathInputKBRelease
|
||||||
|
}
|
||||||
|
buf := []byte{byte(fastPathInputEventUnicode<<5) | flags, byte(code), byte(code >> 8)}
|
||||||
|
return s.Send(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InputSender) AutoExec(command string) error {
|
||||||
|
if err := s.Key(0xE05B, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Key(0xE05B, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
if err := s.Key(0xE05B, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Key(0x0015, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Key(0x0015, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Key(0xE05B, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
if err := s.TypeText(command); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
return s.Key(0x001C, true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
// ScanCodeFromJS 将浏览器 KeyboardEvent.code 转为 RDP 扫描码 // Logan.Li
|
||||||
|
func ScanCodeFromJS(code string) (int, bool) {
|
||||||
|
sc, ok := jsScanCodes[code]
|
||||||
|
return sc, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsScanCodes 将浏览器 KeyboardEvent.code 映射为 RDP 扫描码;扩展键使用 0xE0xx 约定 // Logan.Li
|
||||||
|
var jsScanCodes = map[string]int{
|
||||||
|
"Escape": 0x0001,
|
||||||
|
"Digit1": 0x0002,
|
||||||
|
"Digit2": 0x0003,
|
||||||
|
"Digit3": 0x0004,
|
||||||
|
"Digit4": 0x0005,
|
||||||
|
"Digit5": 0x0006,
|
||||||
|
"Digit6": 0x0007,
|
||||||
|
"Digit7": 0x0008,
|
||||||
|
"Digit8": 0x0009,
|
||||||
|
"Digit9": 0x000A,
|
||||||
|
"Digit0": 0x000B,
|
||||||
|
"Minus": 0x000C,
|
||||||
|
"Equal": 0x000D,
|
||||||
|
"Backspace": 0x000E,
|
||||||
|
"Tab": 0x000F,
|
||||||
|
"KeyQ": 0x0010,
|
||||||
|
"KeyW": 0x0011,
|
||||||
|
"KeyE": 0x0012,
|
||||||
|
"KeyR": 0x0013,
|
||||||
|
"KeyT": 0x0014,
|
||||||
|
"KeyY": 0x0015,
|
||||||
|
"KeyU": 0x0016,
|
||||||
|
"KeyI": 0x0017,
|
||||||
|
"KeyO": 0x0018,
|
||||||
|
"KeyP": 0x0019,
|
||||||
|
"BracketLeft": 0x001A,
|
||||||
|
"BracketRight": 0x001B,
|
||||||
|
"Enter": 0x001C,
|
||||||
|
"ControlLeft": 0x001D,
|
||||||
|
"KeyA": 0x001E,
|
||||||
|
"KeyS": 0x001F,
|
||||||
|
"KeyD": 0x0020,
|
||||||
|
"KeyF": 0x0021,
|
||||||
|
"KeyG": 0x0022,
|
||||||
|
"KeyH": 0x0023,
|
||||||
|
"KeyJ": 0x0024,
|
||||||
|
"KeyK": 0x0025,
|
||||||
|
"KeyL": 0x0026,
|
||||||
|
"Semicolon": 0x0027,
|
||||||
|
"Quote": 0x0028,
|
||||||
|
"Backquote": 0x0029,
|
||||||
|
"ShiftLeft": 0x002A,
|
||||||
|
"Backslash": 0x002B,
|
||||||
|
"KeyZ": 0x002C,
|
||||||
|
"KeyX": 0x002D,
|
||||||
|
"KeyC": 0x002E,
|
||||||
|
"KeyV": 0x002F,
|
||||||
|
"KeyB": 0x0030,
|
||||||
|
"KeyN": 0x0031,
|
||||||
|
"KeyM": 0x0032,
|
||||||
|
"Comma": 0x0033,
|
||||||
|
"Period": 0x0034,
|
||||||
|
"Slash": 0x0035,
|
||||||
|
"ShiftRight": 0x0036,
|
||||||
|
"NumpadMultiply": 0x0037,
|
||||||
|
"AltLeft": 0x0038,
|
||||||
|
"Space": 0x0039,
|
||||||
|
"CapsLock": 0x003A,
|
||||||
|
"F1": 0x003B,
|
||||||
|
"F2": 0x003C,
|
||||||
|
"F3": 0x003D,
|
||||||
|
"F4": 0x003E,
|
||||||
|
"F5": 0x003F,
|
||||||
|
"F6": 0x0040,
|
||||||
|
"F7": 0x0041,
|
||||||
|
"F8": 0x0042,
|
||||||
|
"F9": 0x0043,
|
||||||
|
"F10": 0x0044,
|
||||||
|
"ScrollLock": 0x0046,
|
||||||
|
"Numpad7": 0x0047,
|
||||||
|
"Numpad8": 0x0048,
|
||||||
|
"Numpad9": 0x0049,
|
||||||
|
"NumpadSubtract": 0x004A,
|
||||||
|
"Numpad4": 0x004B,
|
||||||
|
"Numpad5": 0x004C,
|
||||||
|
"Numpad6": 0x004D,
|
||||||
|
"NumpadAdd": 0x004E,
|
||||||
|
"Numpad1": 0x004F,
|
||||||
|
"Numpad2": 0x0050,
|
||||||
|
"Numpad3": 0x0051,
|
||||||
|
"Numpad0": 0x0052,
|
||||||
|
"NumpadDecimal": 0x0053,
|
||||||
|
"F11": 0x0057,
|
||||||
|
"F12": 0x0058,
|
||||||
|
"NumpadEqual": 0x0059,
|
||||||
|
"NumpadEnter": 0xE01C,
|
||||||
|
"ControlRight": 0xE01D,
|
||||||
|
"NumpadDivide": 0xE035,
|
||||||
|
"PrintScreen": 0xE037,
|
||||||
|
"AltRight": 0xE038,
|
||||||
|
"NumLock": 0xE045,
|
||||||
|
"Pause": 0xE046,
|
||||||
|
"Home": 0xE047,
|
||||||
|
"ArrowUp": 0xE048,
|
||||||
|
"PageUp": 0xE049,
|
||||||
|
"ArrowLeft": 0xE04B,
|
||||||
|
"ArrowRight": 0xE04D,
|
||||||
|
"End": 0xE04F,
|
||||||
|
"ArrowDown": 0xE050,
|
||||||
|
"PageDown": 0xE051,
|
||||||
|
"Insert": 0xE052,
|
||||||
|
"Delete": 0xE053,
|
||||||
|
"ContextMenu": 0xE05D,
|
||||||
|
"MetaLeft": 0xE05B,
|
||||||
|
"MetaRight": 0xE05C,
|
||||||
|
"IntlRo": 0x0073,
|
||||||
|
"KanaMode": 0x0070,
|
||||||
|
"IntlYen": 0x007D,
|
||||||
|
"Convert": 0x0079,
|
||||||
|
"NonConvert": 0x007B,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,125 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
// MPPC 解压器实现 RDP 64K 字典压缩(MS-RDPBCGR §3.1.8.4.1)// Logan.Li
|
||||||
|
|
||||||
|
const mppcHistorySize = 65536
|
||||||
|
|
||||||
|
const (
|
||||||
|
mppcBig = 0x01
|
||||||
|
mppcCompressed = 0x20
|
||||||
|
mppcReset = 0x40
|
||||||
|
mppcFlushed = 0x80
|
||||||
|
)
|
||||||
|
|
||||||
|
// MppcDecompressor 维护单连接 MPPC 历史窗口 // Logan.Li
|
||||||
|
type MppcDecompressor struct {
|
||||||
|
history [mppcHistorySize]byte
|
||||||
|
offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMppcDecompressor() *MppcDecompressor {
|
||||||
|
return &MppcDecompressor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *MppcDecompressor) Decompress(flags byte, data []byte) ([]byte, error) {
|
||||||
|
if flags&mppcFlushed != 0 {
|
||||||
|
d.history = [mppcHistorySize]byte{}
|
||||||
|
d.offset = 0
|
||||||
|
} else if flags&mppcReset != 0 {
|
||||||
|
d.offset = 0
|
||||||
|
}
|
||||||
|
if flags&mppcCompressed == 0 {
|
||||||
|
for _, b := range data {
|
||||||
|
d.history[d.offset] = b
|
||||||
|
d.offset = (d.offset + 1) & (mppcHistorySize - 1)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
output := make([]byte, 0, len(data)*3)
|
||||||
|
br := newMppcBitReader(data)
|
||||||
|
outer:
|
||||||
|
for br.bitsLeft >= 9 {
|
||||||
|
if br.readBit() == 0 {
|
||||||
|
b := byte(br.readBits(8))
|
||||||
|
output = append(output, b)
|
||||||
|
d.history[d.offset] = b
|
||||||
|
d.offset = (d.offset + 1) & (mppcHistorySize - 1)
|
||||||
|
} else {
|
||||||
|
sel := (br.readBit() << 1) | br.readBit()
|
||||||
|
var copyOffset int
|
||||||
|
switch sel {
|
||||||
|
case 0:
|
||||||
|
if br.bitsLeft < 6 {
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
copyOffset = br.readBits(6)
|
||||||
|
case 1:
|
||||||
|
if br.bitsLeft < 8 {
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
copyOffset = 64 + br.readBits(8)
|
||||||
|
case 2:
|
||||||
|
if br.bitsLeft < 13 {
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
copyOffset = 320 + br.readBits(13)
|
||||||
|
case 3:
|
||||||
|
if br.bitsLeft < 16 {
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
copyOffset = 8192 + br.readBits(16)
|
||||||
|
}
|
||||||
|
copyLength := 3
|
||||||
|
for br.bitsLeft > 0 {
|
||||||
|
if br.readBit() == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
copyLength++
|
||||||
|
}
|
||||||
|
src := (d.offset - copyOffset - 1 + mppcHistorySize) & (mppcHistorySize - 1)
|
||||||
|
for i := 0; i < copyLength; i++ {
|
||||||
|
b := d.history[(src+i)&(mppcHistorySize-1)]
|
||||||
|
output = append(output, b)
|
||||||
|
d.history[d.offset] = b
|
||||||
|
d.offset = (d.offset + 1) & (mppcHistorySize - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mppcBitReader struct {
|
||||||
|
data []byte
|
||||||
|
byteIdx int
|
||||||
|
mask byte
|
||||||
|
bitsLeft int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMppcBitReader(data []byte) *mppcBitReader {
|
||||||
|
return &mppcBitReader{data: data, mask: 0x80, bitsLeft: len(data) * 8}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mppcBitReader) readBit() int {
|
||||||
|
if r.bitsLeft == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
r.bitsLeft--
|
||||||
|
bit := 0
|
||||||
|
if r.data[r.byteIdx]&r.mask != 0 {
|
||||||
|
bit = 1
|
||||||
|
}
|
||||||
|
r.mask >>= 1
|
||||||
|
if r.mask == 0 {
|
||||||
|
r.mask = 0x80
|
||||||
|
r.byteIdx++
|
||||||
|
}
|
||||||
|
return bit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mppcBitReader) readBits(n int) int {
|
||||||
|
result := 0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
result = (result << 1) | r.readBit()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"ezops/pkg/rdp/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fastPathActionFastPath = 0x0
|
||||||
|
fastPathActionX224 = 0x3
|
||||||
|
)
|
||||||
|
|
||||||
|
// WirePacket 表示从 RDP 连接读取到的一层帧 // Logan.Li
|
||||||
|
type WirePacket struct {
|
||||||
|
FastPath bool
|
||||||
|
SecFlag byte
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadWirePacket 读取 TPKT/X.224 或 FastPath 输出帧 // Logan.Li
|
||||||
|
func ReadWirePacket(r io.Reader) (WirePacket, error) {
|
||||||
|
var hdr [2]byte
|
||||||
|
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||||
|
return WirePacket{}, err
|
||||||
|
}
|
||||||
|
version := hdr[0]
|
||||||
|
if version == fastPathActionX224 {
|
||||||
|
var extHdr [2]byte
|
||||||
|
if _, err := io.ReadFull(r, extHdr[:]); err != nil {
|
||||||
|
return WirePacket{}, err
|
||||||
|
}
|
||||||
|
size := binary.BigEndian.Uint16(extHdr[:])
|
||||||
|
if size < 4 {
|
||||||
|
return WirePacket{}, fmt.Errorf("TPKT 长度非法: %d", size)
|
||||||
|
}
|
||||||
|
body := make([]byte, int(size)-4)
|
||||||
|
if _, err := io.ReadFull(r, body); err != nil {
|
||||||
|
return WirePacket{}, err
|
||||||
|
}
|
||||||
|
if len(body) < 3 || body[1] != 0xF0 {
|
||||||
|
return WirePacket{}, fmt.Errorf("非 X.224 Data PDU")
|
||||||
|
}
|
||||||
|
return WirePacket{Payload: body[3:]}, nil
|
||||||
|
}
|
||||||
|
secFlag := (version >> 6) & 0x3
|
||||||
|
length := int(hdr[1])
|
||||||
|
packetSize := 0
|
||||||
|
if length&0x80 != 0 {
|
||||||
|
var extByte [1]byte
|
||||||
|
if _, err := io.ReadFull(r, extByte[:]); err != nil {
|
||||||
|
return WirePacket{}, err
|
||||||
|
}
|
||||||
|
packetSize = ((length &^ 0x80) << 8) + int(extByte[0]) - 3
|
||||||
|
} else {
|
||||||
|
packetSize = length - 2
|
||||||
|
}
|
||||||
|
if packetSize < 0 {
|
||||||
|
return WirePacket{FastPath: true, SecFlag: secFlag}, nil
|
||||||
|
}
|
||||||
|
body := make([]byte, packetSize)
|
||||||
|
if _, err := io.ReadFull(r, body); err != nil {
|
||||||
|
return WirePacket{}, err
|
||||||
|
}
|
||||||
|
return WirePacket{FastPath: true, SecFlag: secFlag, Payload: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFastPathInput 发送 Fast-Path Input 事件批次 // Logan.Li
|
||||||
|
func WriteFastPathInput(w io.Writer, events []byte) error {
|
||||||
|
hdr := uint16(len(events)+3) | 0x8000
|
||||||
|
frame := make([]byte, 0, len(events)+3)
|
||||||
|
frame = append(frame, fastPathActionFastPath|0x04, byte(hdr>>8), byte(hdr))
|
||||||
|
frame = append(frame, events...)
|
||||||
|
return transport.WriteRaw(w, frame)
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package rle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func decompress1(output *[]uint8, width, height int, input []uint8, size int) bool {
|
||||||
|
var (
|
||||||
|
prevline, line, count int
|
||||||
|
offset, code int
|
||||||
|
x int = width
|
||||||
|
opcode int
|
||||||
|
lastopcode int8 = -1
|
||||||
|
insertmix, bicolour, isfillormix bool
|
||||||
|
mixmask, mask uint8
|
||||||
|
colour1, colour2 uint8
|
||||||
|
mix uint8 = 0xff
|
||||||
|
fom_mask uint8
|
||||||
|
)
|
||||||
|
out := *output
|
||||||
|
for len(input) != 0 {
|
||||||
|
fom_mask = 0
|
||||||
|
code = CVAL(&input)
|
||||||
|
opcode = code >> 4
|
||||||
|
/* Handle different opcode forms */
|
||||||
|
switch opcode {
|
||||||
|
case 0xc, 0xd, 0xe:
|
||||||
|
opcode -= 6
|
||||||
|
count = int(code & 0xf)
|
||||||
|
offset = 16
|
||||||
|
break
|
||||||
|
case 0xf:
|
||||||
|
opcode = code & 0xf
|
||||||
|
if opcode < 9 {
|
||||||
|
count = int(CVAL(&input))
|
||||||
|
count |= int(CVAL(&input) << 8)
|
||||||
|
} else {
|
||||||
|
count = 1
|
||||||
|
if opcode < 0xb {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset = 0
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
opcode >>= 1
|
||||||
|
count = int(code & 0x1f)
|
||||||
|
offset = 32
|
||||||
|
break
|
||||||
|
}
|
||||||
|
/* Handle strange cases for counts */
|
||||||
|
if offset != 0 {
|
||||||
|
isfillormix = ((opcode == 2) || (opcode == 7))
|
||||||
|
if count == 0 {
|
||||||
|
if isfillormix {
|
||||||
|
count = int(CVAL(&input)) + 1
|
||||||
|
} else {
|
||||||
|
count = int(CVAL(&input) + offset)
|
||||||
|
}
|
||||||
|
} else if isfillormix {
|
||||||
|
count <<= 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Read preliminary data */
|
||||||
|
switch opcode {
|
||||||
|
case 0: /* Fill */
|
||||||
|
if (lastopcode == int8(opcode)) && !((x == width) && (prevline == 0)) {
|
||||||
|
insertmix = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 8: /* Bicolour */
|
||||||
|
colour1 = uint8(CVAL(&input))
|
||||||
|
colour2 = uint8(CVAL(&input))
|
||||||
|
break
|
||||||
|
case 3: /* Colour */
|
||||||
|
colour2 = uint8(CVAL(&input))
|
||||||
|
break
|
||||||
|
case 6: /* SetMix/Mix */
|
||||||
|
fallthrough
|
||||||
|
case 7: /* SetMix/FillOrMix */
|
||||||
|
mix = uint8(CVAL(&input))
|
||||||
|
opcode -= 5
|
||||||
|
break
|
||||||
|
case 9: /* FillOrMix_1 */
|
||||||
|
mask = 0x03
|
||||||
|
opcode = 0x02
|
||||||
|
fom_mask = 3
|
||||||
|
break
|
||||||
|
case 0x0a: /* FillOrMix_2 */
|
||||||
|
mask = 0x05
|
||||||
|
opcode = 0x02
|
||||||
|
fom_mask = 5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lastopcode = int8(opcode)
|
||||||
|
mixmask = 0
|
||||||
|
/* Output body */
|
||||||
|
for count > 0 {
|
||||||
|
if x >= width {
|
||||||
|
if height <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
x = 0
|
||||||
|
height--
|
||||||
|
prevline = line
|
||||||
|
line = height * width
|
||||||
|
}
|
||||||
|
switch opcode {
|
||||||
|
case 0: /* Fill */
|
||||||
|
if insertmix {
|
||||||
|
if prevline == 0 {
|
||||||
|
out[x+line] = mix
|
||||||
|
} else {
|
||||||
|
out[x+line] = out[prevline+x] ^ mix
|
||||||
|
}
|
||||||
|
insertmix = false
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
n := min(count, width-x)
|
||||||
|
if prevline == 0 {
|
||||||
|
clear(out[x+line : x+line+n])
|
||||||
|
} else {
|
||||||
|
copy(out[x+line:x+line+n], out[prevline+x:prevline+x+n])
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 1: /* Mix */
|
||||||
|
n := min(count, width-x)
|
||||||
|
if prevline == 0 {
|
||||||
|
seg := out[x+line : x+line+n]
|
||||||
|
for i := range seg {
|
||||||
|
seg[i] = mix
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
src := out[prevline+x : prevline+x+n]
|
||||||
|
dst := out[x+line : x+line+n]
|
||||||
|
for i := range dst {
|
||||||
|
dst[i] = src[i] ^ mix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 2: /* Fill or Mix */
|
||||||
|
if prevline == 0 {
|
||||||
|
for count > 0 && x < width {
|
||||||
|
mixmask <<= 1
|
||||||
|
if mixmask == 0 {
|
||||||
|
mask = fom_mask
|
||||||
|
if fom_mask == 0 {
|
||||||
|
mask = uint8(CVAL(&input))
|
||||||
|
mixmask = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mask&mixmask != 0 {
|
||||||
|
out[x+line] = mix
|
||||||
|
} else {
|
||||||
|
out[x+line] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for count > 0 && x < width {
|
||||||
|
mixmask = mixmask << 1
|
||||||
|
if mixmask == 0 {
|
||||||
|
mask = fom_mask
|
||||||
|
if fom_mask == 0 {
|
||||||
|
mask = uint8(CVAL(&input))
|
||||||
|
mixmask = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mask&mixmask != 0 {
|
||||||
|
out[x+line] = out[prevline+x] ^ mix
|
||||||
|
} else {
|
||||||
|
out[x+line] = out[prevline+x]
|
||||||
|
}
|
||||||
|
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 3: /* Colour */
|
||||||
|
n := min(count, width-x)
|
||||||
|
seg := out[x+line : x+line+n]
|
||||||
|
for i := range seg {
|
||||||
|
seg[i] = colour2
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 4: /* Copy */
|
||||||
|
for count > 0 && x < width {
|
||||||
|
n := min(count, width-x)
|
||||||
|
if len(input) < n {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
copy(out[x+line:x+line+n], input[:n])
|
||||||
|
input = input[n:]
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 8: /* Bicolour */
|
||||||
|
for count > 0 && x < width {
|
||||||
|
if bicolour {
|
||||||
|
out[x+line] = colour2
|
||||||
|
bicolour = false
|
||||||
|
} else {
|
||||||
|
out[x+line] = colour1
|
||||||
|
bicolour = true
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 0xd: /* White */
|
||||||
|
n := min(count, width-x)
|
||||||
|
seg := out[x+line : x+line+n]
|
||||||
|
for i := range seg {
|
||||||
|
seg[i] = 0xff
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 0xe: /* Black */
|
||||||
|
n := min(count, width-x)
|
||||||
|
clear(out[x+line : x+line+n])
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
fmt.Printf("bitmap opcode 0x%x\n", opcode)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// decompress2Pool reuses the intermediate []uint16 buffer across calls to
|
||||||
|
// decompress2, avoiding a large per-frame allocation.
|
||||||
|
var decompress2Pool sync.Pool
|
||||||
|
|
||||||
|
/* 2 byte bitmap decompress */
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package rle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
func decompress2(output *[]uint8, width, height int, input []uint8, size int) bool {
|
||||||
|
needed := width * height
|
||||||
|
|
||||||
|
var out []uint16
|
||||||
|
if v := decompress2Pool.Get(); v != nil {
|
||||||
|
out = v.([]uint16)
|
||||||
|
if cap(out) < needed {
|
||||||
|
out = make([]uint16, needed)
|
||||||
|
} else {
|
||||||
|
out = out[:needed]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out = make([]uint16, needed)
|
||||||
|
}
|
||||||
|
defer func() { decompress2Pool.Put(out[:cap(out)]) }()
|
||||||
|
|
||||||
|
var (
|
||||||
|
prevline, line, count int
|
||||||
|
offset, code int
|
||||||
|
x int = width
|
||||||
|
opcode int
|
||||||
|
lastopcode int = -1
|
||||||
|
insertmix, bicolour, isfillormix bool
|
||||||
|
mixmask, mask uint8
|
||||||
|
colour1, colour2 uint16
|
||||||
|
mix uint16 = 0xffff
|
||||||
|
fom_mask uint8
|
||||||
|
)
|
||||||
|
|
||||||
|
for len(input) != 0 {
|
||||||
|
fom_mask = 0
|
||||||
|
code = CVAL(&input)
|
||||||
|
opcode = code >> 4
|
||||||
|
/* Handle different opcode forms */
|
||||||
|
switch opcode {
|
||||||
|
case 0xc, 0xd, 0xe:
|
||||||
|
opcode -= 6
|
||||||
|
count = code & 0xf
|
||||||
|
offset = 16
|
||||||
|
break
|
||||||
|
case 0xf:
|
||||||
|
opcode = code & 0xf
|
||||||
|
if opcode < 9 {
|
||||||
|
count = CVAL(&input)
|
||||||
|
count |= CVAL(&input) << 8
|
||||||
|
} else {
|
||||||
|
count = 1
|
||||||
|
if opcode < 0xb {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset = 0
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
opcode >>= 1
|
||||||
|
count = code & 0x1f
|
||||||
|
offset = 32
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle strange cases for counts */
|
||||||
|
if offset != 0 {
|
||||||
|
isfillormix = ((opcode == 2) || (opcode == 7))
|
||||||
|
if count == 0 {
|
||||||
|
if isfillormix {
|
||||||
|
count = CVAL(&input) + 1
|
||||||
|
} else {
|
||||||
|
count = CVAL(&input) + offset
|
||||||
|
}
|
||||||
|
} else if isfillormix {
|
||||||
|
count <<= 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Read preliminary data */
|
||||||
|
switch opcode {
|
||||||
|
case 0: /* Fill */
|
||||||
|
if (lastopcode == opcode) && !((x == width) && (prevline == 0)) {
|
||||||
|
insertmix = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 8: /* Bicolour */
|
||||||
|
CVAL2(&input, &colour1)
|
||||||
|
CVAL2(&input, &colour2)
|
||||||
|
break
|
||||||
|
case 3: /* Colour */
|
||||||
|
CVAL2(&input, &colour2)
|
||||||
|
break
|
||||||
|
case 6: /* SetMix/Mix */
|
||||||
|
fallthrough
|
||||||
|
case 7: /* SetMix/FillOrMix */
|
||||||
|
CVAL2(&input, &mix)
|
||||||
|
opcode -= 5
|
||||||
|
break
|
||||||
|
case 9: /* FillOrMix_1 */
|
||||||
|
mask = 0x03
|
||||||
|
opcode = 0x02
|
||||||
|
fom_mask = 3
|
||||||
|
break
|
||||||
|
case 0x0a: /* FillOrMix_2 */
|
||||||
|
mask = 0x05
|
||||||
|
opcode = 0x02
|
||||||
|
fom_mask = 5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lastopcode = opcode
|
||||||
|
mixmask = 0
|
||||||
|
/* Output body */
|
||||||
|
for count > 0 {
|
||||||
|
if x >= width {
|
||||||
|
if height <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
x = 0
|
||||||
|
height--
|
||||||
|
prevline = line
|
||||||
|
line = height * width
|
||||||
|
}
|
||||||
|
switch opcode {
|
||||||
|
case 0: /* Fill */
|
||||||
|
if insertmix {
|
||||||
|
if prevline == 0 {
|
||||||
|
out[x+line] = mix
|
||||||
|
} else {
|
||||||
|
out[x+line] = out[prevline+x] ^ mix
|
||||||
|
}
|
||||||
|
insertmix = false
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
n := min(count, width-x)
|
||||||
|
if prevline == 0 {
|
||||||
|
clear(out[x+line : x+line+n])
|
||||||
|
} else {
|
||||||
|
copy(out[x+line:x+line+n], out[prevline+x:prevline+x+n])
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 1: /* Mix */
|
||||||
|
n := min(count, width-x)
|
||||||
|
if prevline == 0 {
|
||||||
|
seg := out[x+line : x+line+n]
|
||||||
|
for i := range seg {
|
||||||
|
seg[i] = mix
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
src := out[prevline+x : prevline+x+n]
|
||||||
|
dst := out[x+line : x+line+n]
|
||||||
|
for i := range dst {
|
||||||
|
dst[i] = src[i] ^ mix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 2: /* Fill or Mix */
|
||||||
|
if prevline == 0 {
|
||||||
|
for count > 0 && x < width {
|
||||||
|
mixmask <<= 1
|
||||||
|
if mixmask == 0 {
|
||||||
|
mask = fom_mask
|
||||||
|
if fom_mask == 0 {
|
||||||
|
mask = uint8(CVAL(&input))
|
||||||
|
mixmask = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mask&mixmask != 0 {
|
||||||
|
out[x+line] = mix
|
||||||
|
} else {
|
||||||
|
out[x+line] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for count > 0 && x < width {
|
||||||
|
mixmask = mixmask << 1
|
||||||
|
if mixmask == 0 {
|
||||||
|
mask = fom_mask
|
||||||
|
if fom_mask == 0 {
|
||||||
|
mask = uint8(CVAL(&input))
|
||||||
|
mixmask = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mask&mixmask != 0 {
|
||||||
|
out[x+line] = out[prevline+x] ^ mix
|
||||||
|
} else {
|
||||||
|
out[x+line] = out[prevline+x]
|
||||||
|
}
|
||||||
|
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 3: /* Colour */
|
||||||
|
n := min(count, width-x)
|
||||||
|
seg := out[x+line : x+line+n]
|
||||||
|
for i := range seg {
|
||||||
|
seg[i] = colour2
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 4: /* Copy */
|
||||||
|
for count > 0 && x < width {
|
||||||
|
n := min(count, width-x)
|
||||||
|
if len(input) < n*2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
copy(out[x+line:x+line+n], unsafe.Slice((*uint16)(unsafe.Pointer(&input[0])), n))
|
||||||
|
input = input[n*2:]
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case 8: /* Bicolour */
|
||||||
|
for count > 0 && x < width {
|
||||||
|
if bicolour {
|
||||||
|
out[x+line] = colour2
|
||||||
|
bicolour = false
|
||||||
|
} else {
|
||||||
|
out[x+line] = colour1
|
||||||
|
bicolour = true
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case 0xd: /* White */
|
||||||
|
n2 := min(count, width-x)
|
||||||
|
seg2 := out[x+line : x+line+n2]
|
||||||
|
for i := range seg2 {
|
||||||
|
seg2[i] = 0xffff
|
||||||
|
}
|
||||||
|
count -= n2
|
||||||
|
x += n2
|
||||||
|
break
|
||||||
|
case 0xe: /* Black */
|
||||||
|
n3 := min(count, width-x)
|
||||||
|
clear(out[x+line : x+line+n3])
|
||||||
|
count -= n3
|
||||||
|
x += n3
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
fmt.Printf("bitmap opcode 0x%x\n", opcode)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outBytes := *output
|
||||||
|
for i, v := range out {
|
||||||
|
outBytes[i*2] = byte(v >> 8)
|
||||||
|
outBytes[i*2+1] = byte(v)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// /* 3 byte bitmap decompress */
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
package rle
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func decompress3(output *[]uint8, width, height int, input []uint8, size int) bool {
|
||||||
|
var (
|
||||||
|
prevline, line, count int
|
||||||
|
opcode, offset, code int
|
||||||
|
x int = width
|
||||||
|
lastopcode int = -1
|
||||||
|
insertmix, bicolour, isfillormix bool
|
||||||
|
mixmask, mask uint8
|
||||||
|
colour1 = [3]uint8{0, 0, 0}
|
||||||
|
colour2 = [3]uint8{0, 0, 0}
|
||||||
|
mix = [3]uint8{0xff, 0xff, 0xff}
|
||||||
|
fom_mask uint8
|
||||||
|
)
|
||||||
|
out := *output
|
||||||
|
for len(input) != 0 {
|
||||||
|
fom_mask = 0
|
||||||
|
code = CVAL(&input)
|
||||||
|
opcode = code >> 4
|
||||||
|
/* Handle different opcode forms */
|
||||||
|
switch opcode {
|
||||||
|
case 0xc, 0xd, 0xe:
|
||||||
|
opcode -= 6
|
||||||
|
count = code & 0xf
|
||||||
|
offset = 16
|
||||||
|
break
|
||||||
|
case 0xf:
|
||||||
|
opcode = code & 0xf
|
||||||
|
if opcode < 9 {
|
||||||
|
count = CVAL(&input)
|
||||||
|
count |= CVAL(&input) << 8
|
||||||
|
} else {
|
||||||
|
count = 1
|
||||||
|
if opcode < 0xb {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset = 0
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
opcode >>= 1
|
||||||
|
count = code & 0x1f
|
||||||
|
offset = 32
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle strange cases for counts */
|
||||||
|
if offset != 0 {
|
||||||
|
isfillormix = ((opcode == 2) || (opcode == 7))
|
||||||
|
if count == 0 {
|
||||||
|
if isfillormix {
|
||||||
|
count = CVAL(&input) + 1
|
||||||
|
} else {
|
||||||
|
count = CVAL(&input) + offset
|
||||||
|
}
|
||||||
|
} else if isfillormix {
|
||||||
|
count <<= 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Read preliminary data */
|
||||||
|
switch opcode {
|
||||||
|
case 0: /* Fill */
|
||||||
|
if (lastopcode == opcode) && !((x == width) && (prevline == 0)) {
|
||||||
|
insertmix = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 8: /* Bicolour */
|
||||||
|
CVAL3(&input, &colour1)
|
||||||
|
CVAL3(&input, &colour2)
|
||||||
|
break
|
||||||
|
case 3: /* Colour */
|
||||||
|
CVAL3(&input, &colour2)
|
||||||
|
break
|
||||||
|
case 6: /* SetMix/Mix */
|
||||||
|
fallthrough
|
||||||
|
case 7: /* SetMix/FillOrMix */
|
||||||
|
CVAL3(&input, &mix)
|
||||||
|
opcode -= 5
|
||||||
|
break
|
||||||
|
case 9: /* FillOrMix_1 */
|
||||||
|
mask = 0x03
|
||||||
|
opcode = 0x02
|
||||||
|
fom_mask = 3
|
||||||
|
break
|
||||||
|
case 0x0a: /* FillOrMix_2 */
|
||||||
|
mask = 0x05
|
||||||
|
opcode = 0x02
|
||||||
|
fom_mask = 5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
lastopcode = opcode
|
||||||
|
mixmask = 0
|
||||||
|
/* Output body */
|
||||||
|
for count > 0 {
|
||||||
|
if x >= width {
|
||||||
|
if height <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
x = 0
|
||||||
|
height--
|
||||||
|
prevline = line
|
||||||
|
line = height * width * 3
|
||||||
|
}
|
||||||
|
switch opcode {
|
||||||
|
case 0: /* Fill */
|
||||||
|
if insertmix {
|
||||||
|
if prevline == 0 {
|
||||||
|
out[3*x+line] = mix[0]
|
||||||
|
out[3*x+line+1] = mix[1]
|
||||||
|
out[3*x+line+2] = mix[2]
|
||||||
|
} else {
|
||||||
|
out[3*x+line] = out[prevline+3*x] ^ mix[0]
|
||||||
|
out[3*x+line+1] = out[prevline+3*x+1] ^ mix[1]
|
||||||
|
out[3*x+line+2] = out[prevline+3*x+2] ^ mix[2]
|
||||||
|
}
|
||||||
|
insertmix = false
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
n := min(count, width-x)
|
||||||
|
if prevline == 0 {
|
||||||
|
clear(out[3*x+line : 3*x+line+3*n])
|
||||||
|
} else {
|
||||||
|
dstBase := 3*x + line
|
||||||
|
srcBase := prevline + 3*x
|
||||||
|
copy(out[dstBase:dstBase+3*n], out[srcBase:srcBase+3*n])
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 1: /* Mix */
|
||||||
|
n := min(count, width-x)
|
||||||
|
dst1 := out[3*x+line : 3*x+line+3*n]
|
||||||
|
if prevline == 0 {
|
||||||
|
// Exponential-doubling copy: O(log n) memcpy calls
|
||||||
|
dst1[0], dst1[1], dst1[2] = mix[0], mix[1], mix[2]
|
||||||
|
for wrote := 3; wrote < len(dst1); {
|
||||||
|
wrote += copy(dst1[wrote:], dst1[:wrote])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
src1 := out[prevline+3*x : prevline+3*x+3*n]
|
||||||
|
for i := 0; i+2 < len(dst1); i += 3 {
|
||||||
|
dst1[i] = src1[i] ^ mix[0]
|
||||||
|
dst1[i+1] = src1[i+1] ^ mix[1]
|
||||||
|
dst1[i+2] = src1[i+2] ^ mix[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 2: /* Fill or Mix */
|
||||||
|
if prevline == 0 {
|
||||||
|
base := 3*x + line
|
||||||
|
for count > 0 && x < width {
|
||||||
|
mixmask = mixmask << 1
|
||||||
|
if mixmask == 0 {
|
||||||
|
mask = fom_mask
|
||||||
|
if fom_mask == 0 {
|
||||||
|
mask = uint8(CVAL(&input))
|
||||||
|
mixmask = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mask&mixmask != 0 {
|
||||||
|
out[base] = mix[0]
|
||||||
|
out[base+1] = mix[1]
|
||||||
|
out[base+2] = mix[2]
|
||||||
|
} else {
|
||||||
|
out[base] = 0
|
||||||
|
out[base+1] = 0
|
||||||
|
out[base+2] = 0
|
||||||
|
}
|
||||||
|
base += 3
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base := 3*x + line
|
||||||
|
prev := prevline + 3*x
|
||||||
|
for count > 0 && x < width {
|
||||||
|
mixmask = mixmask << 1
|
||||||
|
if mixmask == 0 {
|
||||||
|
mask = fom_mask
|
||||||
|
if fom_mask == 0 {
|
||||||
|
mask = uint8(CVAL(&input))
|
||||||
|
mixmask = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mask&mixmask != 0 {
|
||||||
|
out[base] = out[prev] ^ mix[0]
|
||||||
|
out[base+1] = out[prev+1] ^ mix[1]
|
||||||
|
out[base+2] = out[prev+2] ^ mix[2]
|
||||||
|
} else {
|
||||||
|
out[base] = out[prev]
|
||||||
|
out[base+1] = out[prev+1]
|
||||||
|
out[base+2] = out[prev+2]
|
||||||
|
}
|
||||||
|
base += 3
|
||||||
|
prev += 3
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 3: /* Colour */
|
||||||
|
n := min(count, width-x)
|
||||||
|
seg3 := out[3*x+line : 3*x+line+3*n]
|
||||||
|
seg3[0], seg3[1], seg3[2] = colour2[0], colour2[1], colour2[2]
|
||||||
|
for wrote := 3; wrote < len(seg3); {
|
||||||
|
wrote += copy(seg3[wrote:], seg3[:wrote])
|
||||||
|
}
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
break
|
||||||
|
case 4: /* Copy */
|
||||||
|
for count > 0 && x < width {
|
||||||
|
n := min(count, width-x)
|
||||||
|
if len(input) < n*3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
copy(out[3*x+line:3*x+line+n*3], input[:n*3])
|
||||||
|
input = input[n*3:]
|
||||||
|
count -= n
|
||||||
|
x += n
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 8: /* Bicolour */
|
||||||
|
base8 := 3*x + line
|
||||||
|
for count > 0 && x < width {
|
||||||
|
if bicolour {
|
||||||
|
out[base8] = colour2[0]
|
||||||
|
out[base8+1] = colour2[1]
|
||||||
|
out[base8+2] = colour2[2]
|
||||||
|
bicolour = false
|
||||||
|
} else {
|
||||||
|
out[base8] = colour1[0]
|
||||||
|
out[base8+1] = colour1[1]
|
||||||
|
out[base8+2] = colour1[2]
|
||||||
|
bicolour = true
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
base8 += 3
|
||||||
|
count--
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 0xd: /* White */
|
||||||
|
n3 := min(count, width-x)
|
||||||
|
seg3 := out[3*x+line : 3*x+line+3*n3]
|
||||||
|
for i := range seg3 {
|
||||||
|
seg3[i] = 0xff
|
||||||
|
}
|
||||||
|
count -= n3
|
||||||
|
x += n3
|
||||||
|
break
|
||||||
|
case 0xe: /* Black */
|
||||||
|
n2 := min(count, width-x)
|
||||||
|
clear(out[3*x+line : 3*x+line+3*n2])
|
||||||
|
count -= n2
|
||||||
|
x += n2
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
fmt.Printf("bitmap opcode 0x%x\n", opcode)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/* decompress a colour plane */
|
||||||
|
func processPlane(in *[]uint8, width, height int, output *[]uint8, j int) (int, bool) {
|
||||||
|
var (
|
||||||
|
indexw int
|
||||||
|
indexh int
|
||||||
|
code int
|
||||||
|
collen int
|
||||||
|
replen int
|
||||||
|
color uint8
|
||||||
|
x uint8
|
||||||
|
revcode int
|
||||||
|
lastline int
|
||||||
|
thisline int
|
||||||
|
)
|
||||||
|
ln := len(*in)
|
||||||
|
out := *output // hoist pointer dereference; writes to out[i] affect the underlying array
|
||||||
|
|
||||||
|
readByte := func() (uint8, bool) {
|
||||||
|
if len(*in) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v := (*in)[0]
|
||||||
|
*in = (*in)[1:]
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
writePixel := func(pos int, value uint8) bool {
|
||||||
|
if pos < 0 || pos >= len(out) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out[pos] = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
lastline = 0
|
||||||
|
indexh = 0
|
||||||
|
i := 0
|
||||||
|
for indexh < height {
|
||||||
|
thisline = j + (width * height * 4) - ((indexh + 1) * width * 4)
|
||||||
|
color = 0
|
||||||
|
indexw = 0
|
||||||
|
i = thisline
|
||||||
|
|
||||||
|
if lastline == 0 {
|
||||||
|
for indexw < width {
|
||||||
|
b, ok := readByte()
|
||||||
|
if !ok {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
code = int(b)
|
||||||
|
replen = int(code & 0xf)
|
||||||
|
collen = int((code >> 4) & 0xf)
|
||||||
|
revcode = (replen << 4) | collen
|
||||||
|
if (revcode <= 47) && (revcode >= 16) {
|
||||||
|
replen = revcode
|
||||||
|
collen = 0
|
||||||
|
}
|
||||||
|
for collen > 0 {
|
||||||
|
if indexw >= width {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
v, ok := readByte()
|
||||||
|
if !ok {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
color = v
|
||||||
|
if !writePixel(i, color) {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
i += 4
|
||||||
|
|
||||||
|
indexw++
|
||||||
|
collen--
|
||||||
|
}
|
||||||
|
for replen > 0 {
|
||||||
|
if indexw >= width {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !writePixel(i, color) {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
i += 4
|
||||||
|
indexw++
|
||||||
|
replen--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// prevOffset is constant per row: indexw*4+lastline == i+(lastline-thisline)
|
||||||
|
// because i == thisline+indexw*4. Pre-computing it eliminates a multiply
|
||||||
|
// per pixel in both inner loops.
|
||||||
|
prevOffset := lastline - thisline
|
||||||
|
for indexw < width {
|
||||||
|
b, ok := readByte()
|
||||||
|
if !ok {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
code = int(b)
|
||||||
|
replen = int(code & 0xf)
|
||||||
|
collen = int((code >> 4) & 0xf)
|
||||||
|
revcode = (replen << 4) | collen
|
||||||
|
if (revcode <= 47) && (revcode >= 16) {
|
||||||
|
replen = revcode
|
||||||
|
collen = 0
|
||||||
|
}
|
||||||
|
for collen > 0 {
|
||||||
|
if indexw >= width {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
v, ok := readByte()
|
||||||
|
if !ok {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
x = v
|
||||||
|
if x&1 != 0 {
|
||||||
|
x = x >> 1
|
||||||
|
x = x + 1
|
||||||
|
color = -x
|
||||||
|
} else {
|
||||||
|
x = x >> 1
|
||||||
|
color = x
|
||||||
|
}
|
||||||
|
if i+prevOffset < 0 || i+prevOffset >= len(out) {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
x = out[i+prevOffset] + color
|
||||||
|
if !writePixel(i, x) {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
i += 4
|
||||||
|
indexw++
|
||||||
|
collen--
|
||||||
|
}
|
||||||
|
for replen > 0 {
|
||||||
|
if indexw >= width {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i+prevOffset < 0 || i+prevOffset >= len(out) {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
x = out[i+prevOffset] + color
|
||||||
|
if !writePixel(i, x) {
|
||||||
|
return ln - len(*in), false
|
||||||
|
}
|
||||||
|
i += 4
|
||||||
|
indexw++
|
||||||
|
replen--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
indexh++
|
||||||
|
lastline = thisline
|
||||||
|
}
|
||||||
|
return ln - len(*in), true
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4 byte bitmap decompress */
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package rle
|
||||||
|
|
||||||
|
func decompress4(output *[]uint8, width, height int, input []uint8, size int) bool {
|
||||||
|
var (
|
||||||
|
code int
|
||||||
|
onceBytes, total int
|
||||||
|
)
|
||||||
|
|
||||||
|
code = CVAL(&input)
|
||||||
|
rle := code&0x10 != 0
|
||||||
|
noAlpha := code&0x20 != 0
|
||||||
|
|
||||||
|
if !rle {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
total = 1
|
||||||
|
out := *output
|
||||||
|
|
||||||
|
if noAlpha {
|
||||||
|
// No alpha plane in the stream; fill alpha channel with 0xFF.
|
||||||
|
for i := 3; i < len(out); i += 4 {
|
||||||
|
out[i] = 0xFF
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var ok bool
|
||||||
|
onceBytes, ok = processPlane(&input, width, height, output, 3)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
total += onceBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
onceBytes, ok = processPlane(&input, width, height, output, 2)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
total += onceBytes
|
||||||
|
|
||||||
|
onceBytes, ok = processPlane(&input, width, height, output, 1)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
total += onceBytes
|
||||||
|
|
||||||
|
onceBytes, ok = processPlane(&input, width, height, output, 0)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
total += onceBytes
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecompressInto decompresses bitmap data into dst, reusing dst if it has
|
||||||
|
// sufficient capacity (size = width*height*bpp). If dst is nil or too small
|
||||||
|
// a new slice is allocated. Returns the (re)used output slice.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package rle
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func DecompressInto(input []uint8, dst []uint8, width, height int, bpp int) []uint8 {
|
||||||
|
size := width * height * bpp
|
||||||
|
if cap(dst) >= size {
|
||||||
|
dst = dst[:size]
|
||||||
|
} else {
|
||||||
|
dst = make([]uint8, size)
|
||||||
|
}
|
||||||
|
ok := true
|
||||||
|
switch bpp {
|
||||||
|
case 1:
|
||||||
|
ok = decompress1(&dst, width, height, input, size)
|
||||||
|
case 2:
|
||||||
|
ok = decompress2(&dst, width, height, input, size)
|
||||||
|
case 3:
|
||||||
|
ok = decompress3(&dst, width, height, input, size)
|
||||||
|
case 4:
|
||||||
|
ok = decompress4(&dst, width, height, input, size)
|
||||||
|
default:
|
||||||
|
fmt.Printf("bpp %d\n", bpp)
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package rle
|
||||||
|
|
||||||
|
import "unsafe"
|
||||||
|
|
||||||
|
func CVAL(p *[]uint8) int {
|
||||||
|
a := int((*p)[0])
|
||||||
|
*p = (*p)[1:]
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func CVAL2(p *[]uint8, v *uint16) {
|
||||||
|
*v = *((*uint16)(unsafe.Pointer(&(*p)[0])))
|
||||||
|
*p = (*p)[2:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func CVAL3(p *[]uint8, v *[3]uint8) {
|
||||||
|
(*v)[0] = (*p)[0]
|
||||||
|
(*v)[1] = (*p)[1]
|
||||||
|
(*v)[2] = (*p)[2]
|
||||||
|
*p = (*p)[3:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func REPEAT(f func(), count *int, x *int, width int) {
|
||||||
|
for *count > 0 && *x < width {
|
||||||
|
f()
|
||||||
|
*count--
|
||||||
|
*x++
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/pkg/rdp/credssp"
|
||||||
|
"ezops/pkg/rdp/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session 保存已建立的 RDP 图形会话状态 // Logan.Li
|
||||||
|
type Session struct {
|
||||||
|
Conn io.ReadWriter
|
||||||
|
UserID uint16
|
||||||
|
SharedID uint32
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSession 完成 CredSSP + MCS/GCC 握手并进入可收帧状态 // Logan.Li
|
||||||
|
func OpenSession(ctx context.Context, cfg Config) (*Session, error) {
|
||||||
|
port := cfg.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 3389
|
||||||
|
}
|
||||||
|
if cfg.MCSMode == "" {
|
||||||
|
cfg.MCSMode = strings.TrimSpace(os.Getenv("RDP_MCS_MODE"))
|
||||||
|
}
|
||||||
|
if cfg.MCSMode == "" {
|
||||||
|
cfg.MCSMode = "grdp-modern"
|
||||||
|
}
|
||||||
|
address := fmt.Sprintf("%s:%d", cfg.Host, port)
|
||||||
|
strategies := []credssp.Options{
|
||||||
|
{Version: 6, PubKeyStyle: "binding"},
|
||||||
|
{Version: 6, PubKeyStyle: "binding", UseCBT: true},
|
||||||
|
{Version: 6, PubKeyStyle: "binding", ForceMIC: true},
|
||||||
|
{Version: 2, PubKeyStyle: "raw"},
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, opts := range strategies {
|
||||||
|
sess, err := openSessionOnce(ctx, cfg, address, opts)
|
||||||
|
if err == nil {
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("RDP 会话建立失败")
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSessionOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (*Session, error) {
|
||||||
|
tlsConn, _, err := dialCredSSPOnce(ctx, cfg, address, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = tlsConn.SetDeadline(time.Now().Add(45 * time.Second))
|
||||||
|
if err := transport.WriteX224Data(tlsConn, buildMCSConnectInitial(cfg)); err != nil {
|
||||||
|
_ = tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
payload, err := transport.ReadX224Data(tlsConn)
|
||||||
|
if err != nil {
|
||||||
|
_ = tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := parseMCSConnectResponse(payload); err != nil {
|
||||||
|
_ = tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userID, err := attachMCSUser(tlsConn)
|
||||||
|
if err != nil {
|
||||||
|
_ = tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mcsInfo, _ := parseMCSConnectResponse(payload)
|
||||||
|
joinIDs := append([]uint16{mcsGlobalChannelID, userID}, mcsInfo.ServerChannelIDs...)
|
||||||
|
for _, channelID := range joinIDs {
|
||||||
|
if _, err := joinMCSChannel(tlsConn, userID, channelID); err != nil {
|
||||||
|
_ = tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, demand, err := sendClientInfoAndReadDemand(tlsConn, cfg, userID)
|
||||||
|
if err != nil {
|
||||||
|
_ = tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand); err != nil {
|
||||||
|
_ = tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := requestDisplayUpdates(tlsConn, cfg, userID, demand.sharedID); err != nil {
|
||||||
|
_ = tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
width := cfg.Width
|
||||||
|
if width <= 0 {
|
||||||
|
width = 1280
|
||||||
|
}
|
||||||
|
height := cfg.Height
|
||||||
|
if height <= 0 {
|
||||||
|
height = 800
|
||||||
|
}
|
||||||
|
_ = tlsConn.SetDeadline(time.Time{})
|
||||||
|
return &Session{
|
||||||
|
Conn: tlsConn,
|
||||||
|
UserID: userID,
|
||||||
|
SharedID: demand.sharedID,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestDisplayUpdates(rw io.ReadWriter, cfg Config, userID uint16, sharedID uint32) error {
|
||||||
|
if conn, ok := rw.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
pkt, err := ReadWirePacket(rw)
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
if err == nil && !pkt.FastPath {
|
||||||
|
_, _ = parseMCSDataIndication(pkt.Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := buildSuppressOutputBody(cfg, true)
|
||||||
|
pdu := buildDataPDU(userID, sharedID, pduType2SuppressOutput, body)
|
||||||
|
if err := transport.WriteX224Data(rw, buildMCSSendDataRequest(userID, mcsGlobalChannelID, pdu)); err != nil {
|
||||||
|
return fmt.Errorf("发送 SuppressOutput 失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSuppressOutputBody(cfg Config, allow bool) []byte {
|
||||||
|
width := cfg.Width
|
||||||
|
if width <= 0 {
|
||||||
|
width = 1280
|
||||||
|
}
|
||||||
|
height := cfg.Height
|
||||||
|
if height <= 0 {
|
||||||
|
height = 800
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, 12)
|
||||||
|
if allow {
|
||||||
|
out = append(out, 1, 0, 0, 0)
|
||||||
|
} else {
|
||||||
|
out = append(out, 0, 0, 0, 0)
|
||||||
|
}
|
||||||
|
out = append(out, 0, 0, 0, 0, byte(width-1), byte((width-1)>>8), byte(height-1), byte((height-1)>>8))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRefreshRectBody(cfg Config) []byte {
|
||||||
|
width := cfg.Width
|
||||||
|
if width <= 0 {
|
||||||
|
width = 1280
|
||||||
|
}
|
||||||
|
height := cfg.Height
|
||||||
|
if height <= 0 {
|
||||||
|
height = 800
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, 10)
|
||||||
|
out = append(out, 1, 0)
|
||||||
|
out = append(out, 0, 0, 0, 0, byte(width-1), byte((width-1)>>8), byte(height-1), byte((height-1)>>8))
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/pkg/rdp/display"
|
||||||
|
"ezops/pkg/rdp/trace"
|
||||||
|
"ezops/pkg/rdp/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionEngine 是自研完整 RDP 图形会话引擎 // Logan.Li
|
||||||
|
type SessionEngine struct{}
|
||||||
|
|
||||||
|
func (e *SessionEngine) Run(ctx context.Context, cfg Config, sink FrameSink, events <-chan ClientEvent) error {
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 3389
|
||||||
|
}
|
||||||
|
trace.Logf("engine=rdp connect target=%s:%d size=%dx%d security=%s", cfg.Host, cfg.Port, cfg.Width, cfg.Height, cfg.Security)
|
||||||
|
_ = sink.SendFrame(ServerFrame{
|
||||||
|
Type: "status",
|
||||||
|
Message: fmt.Sprintf("正在连接 RDP %s:%d", cfg.Host, cfg.Port),
|
||||||
|
})
|
||||||
|
|
||||||
|
sess, err := OpenSession(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
trace.Logf("engine=rdp open failed target=%s:%d err=%v", cfg.Host, cfg.Port, err)
|
||||||
|
_ = sink.SendFrame(ServerFrame{Type: "error", Message: "RDP 会话建立失败:" + err.Error()})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if c, ok := sess.Conn.(io.Closer); ok {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = sink.SendFrame(ServerFrame{Type: "status", Message: "RDP 图形会话已建立,等待桌面画面..."})
|
||||||
|
trace.Logf("engine=rdp open ok target=%s:%d", cfg.Host, cfg.Port)
|
||||||
|
writeMu := &sync.Mutex{}
|
||||||
|
input := NewInputSenderWithLock(sess.Conn, writeMu)
|
||||||
|
receiver := NewFastPathReceiver()
|
||||||
|
|
||||||
|
readErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
readErr <- e.readLoop(ctx, sess, receiver, sink, writeMu)
|
||||||
|
}()
|
||||||
|
go e.inputLoop(ctx, input, sink, events)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case err := <-readErr:
|
||||||
|
if err != nil {
|
||||||
|
_ = sink.SendFrame(ServerFrame{Type: "error", Message: err.Error()})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SessionEngine) inputLoop(ctx context.Context, input *InputSender, sink FrameSink, events <-chan ClientEvent) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case event, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Type != "mousemove" && event.Type != "mouse_move" {
|
||||||
|
trace.Logf("engine=rdp input type=%s x=%d y=%d button=%d code=%s down=%t", event.Type, event.X, event.Y, event.Button, event.Code, event.Down)
|
||||||
|
}
|
||||||
|
if err := e.handleEvent(input, sink, event); err != nil {
|
||||||
|
_ = sink.SendFrame(ServerFrame{Type: "status", Message: "输入事件失败:" + err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *FastPathReceiver, sink FrameSink, writeMu *sync.Mutex) error {
|
||||||
|
framebuffer := display.NewFramebuffer(sess.Width, sess.Height)
|
||||||
|
ticker := time.NewTicker(16 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
refreshRequested := false
|
||||||
|
flushFrame := func() {
|
||||||
|
frame, ok := framebuffer.FlushRGBA()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = sink.SendFrame(ServerFrame{
|
||||||
|
Type: "bitmap",
|
||||||
|
X: frame.X,
|
||||||
|
Y: frame.Y,
|
||||||
|
Width: frame.Width,
|
||||||
|
Height: frame.Height,
|
||||||
|
RGBA: frame.RGBA,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sendBitmaps := func(rects []BitmapRect) {
|
||||||
|
for _, rect := range rects {
|
||||||
|
if rect.Width <= 0 || rect.Height <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !refreshRequested && isBlackFullScreen(rect, sess.Width, sess.Height) {
|
||||||
|
refreshRequested = true
|
||||||
|
go func() {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
if err := e.requestRefresh(sess, writeMu); err != nil {
|
||||||
|
trace.Logf("engine=rdp refresh request failed err=%v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
framebuffer.Draw(display.Bitmap{
|
||||||
|
X: rect.DestLeft,
|
||||||
|
Y: rect.DestTop,
|
||||||
|
Width: rect.Width,
|
||||||
|
Height: rect.Height,
|
||||||
|
BitsPerPixel: rect.BitsPerPixel,
|
||||||
|
Data: rect.Data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type packetResult struct {
|
||||||
|
packet WirePacket
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
packetCh := make(chan packetResult, 1)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
pkt, err := ReadWirePacket(sess.Conn)
|
||||||
|
select {
|
||||||
|
case packetCh <- packetResult{packet: pkt, err: err}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
flushFrame()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
flushFrame()
|
||||||
|
case got := <-packetCh:
|
||||||
|
if got.err != nil {
|
||||||
|
flushFrame()
|
||||||
|
return got.err
|
||||||
|
}
|
||||||
|
var rects []BitmapRect
|
||||||
|
var err error
|
||||||
|
if got.packet.FastPath {
|
||||||
|
rects, err = receiver.Handle(got.packet.SecFlag, got.packet.Payload)
|
||||||
|
} else {
|
||||||
|
rects, err = receiver.HandleSlowPath(got.packet.Payload)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := e.ackSurfaceFrames(sess, receiver.TakeFrameIDs(), writeMu); err != nil {
|
||||||
|
trace.Logf("engine=rdp frame ack failed err=%v", err)
|
||||||
|
}
|
||||||
|
if len(rects) > 0 {
|
||||||
|
trace.Logf("engine=rdp bitmap rects=%d fastpath=%t", len(rects), got.packet.FastPath)
|
||||||
|
sendBitmaps(rects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBlackFullScreen(rect BitmapRect, width, height int) bool {
|
||||||
|
if rect.DestLeft != 0 || rect.DestTop != 0 || rect.Width < width || rect.Height < height {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
limit := len(rect.Data)
|
||||||
|
if limit > 256*1024 {
|
||||||
|
limit = 256 * 1024
|
||||||
|
}
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
if rect.Data[i] > 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limit > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SessionEngine) requestRefresh(sess *Session, writeMu *sync.Mutex) error {
|
||||||
|
body := buildRefreshRectBody(Config{Width: sess.Width, Height: sess.Height})
|
||||||
|
pdu := buildDataPDU(sess.UserID, sess.SharedID, pduType2RefreshRect, body)
|
||||||
|
trace.Logf("engine=rdp refresh rect %dx%d", sess.Width, sess.Height)
|
||||||
|
writeMu.Lock()
|
||||||
|
err := transport.WriteX224Data(sess.Conn, buildMCSSendDataRequest(sess.UserID, mcsGlobalChannelID, pdu))
|
||||||
|
writeMu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SessionEngine) ackSurfaceFrames(sess *Session, frameIDs []uint32, writeMu *sync.Mutex) error {
|
||||||
|
for _, frameID := range frameIDs {
|
||||||
|
body := []byte{byte(frameID), byte(frameID >> 8), byte(frameID >> 16), byte(frameID >> 24)}
|
||||||
|
pdu := buildDataPDU(sess.UserID, sess.SharedID, pduType2FrameAcknowledge, body)
|
||||||
|
trace.Logf("engine=rdp frame ack frameID=%d", frameID)
|
||||||
|
writeMu.Lock()
|
||||||
|
err := transport.WriteX224Data(sess.Conn, buildMCSSendDataRequest(sess.UserID, mcsGlobalChannelID, pdu))
|
||||||
|
writeMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event ClientEvent) error {
|
||||||
|
switch event.Type {
|
||||||
|
case "mousemove", "mouse_move":
|
||||||
|
return input.MouseMove(event.X, event.Y)
|
||||||
|
case "mousedown", "mouse_down":
|
||||||
|
return input.MouseButton(event.X, event.Y, event.Button, true)
|
||||||
|
case "mouseup", "mouse_up":
|
||||||
|
return input.MouseButton(event.X, event.Y, event.Button, false)
|
||||||
|
case "wheel":
|
||||||
|
return input.MouseWheel(event.X, event.Y, event.DeltaY)
|
||||||
|
case "key":
|
||||||
|
sc, ok := ScanCodeFromJS(event.Code)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return input.Key(sc, event.Down)
|
||||||
|
case "clipboard":
|
||||||
|
if event.Text != "" {
|
||||||
|
return input.TypeText(event.Text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "auto_exec":
|
||||||
|
if event.Text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = sink.SendFrame(ServerFrame{Type: "status", Message: "正在远端执行:" + event.Text})
|
||||||
|
return input.AutoExec(event.Text)
|
||||||
|
case "resize":
|
||||||
|
_ = sink.SendFrame(ServerFrame{Type: "status", Message: "分辨率变更请求已记录,重连后生效"})
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeSessionFrames 用于诊断:建立会话后读取若干秒位图帧数量 // Logan.Li
|
||||||
|
func ProbeSessionFrames(ctx context.Context, cfg Config, seconds int) (int, error) {
|
||||||
|
sess, err := OpenSession(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if c, ok := sess.Conn.(io.Closer); ok {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
receiver := NewFastPathReceiver()
|
||||||
|
deadline := time.Now().Add(time.Duration(seconds) * time.Second)
|
||||||
|
count := 0
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if c, ok := sess.Conn.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||||
|
_ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||||
|
}
|
||||||
|
pkt, err := ReadWirePacket(sess.Conn)
|
||||||
|
if err != nil {
|
||||||
|
if time.Now().Before(deadline) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
var rects []BitmapRect
|
||||||
|
if pkt.FastPath {
|
||||||
|
rects, err = receiver.Handle(pkt.SecFlag, pkt.Payload)
|
||||||
|
} else {
|
||||||
|
rects, err = receiver.HandleSlowPath(pkt.Payload)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
count += len(rects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c, ok := sess.Conn.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||||
|
_ = c.SetReadDeadline(time.Time{})
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package trace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enabled 判断是否开启 RDP 协议级调试日志,默认关闭避免高频帧刷屏 // Logan.Li
|
||||||
|
func Enabled() bool {
|
||||||
|
v := strings.ToLower(strings.TrimSpace(os.Getenv("RDP_DEBUG")))
|
||||||
|
return v == "1" || v == "true" || v == "yes" || v == "debug"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logf 输出 RDP 调试日志,统一前缀便于 docker logs 过滤 // Logan.Li
|
||||||
|
func Logf(format string, args ...interface{}) {
|
||||||
|
if !Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[RDP:TRACE] "+format, args...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpgradeTLS 在 X.224 协商后升级为 TLS,RDP 自签证书由堡垒机侧 TOFU 处理 // Logan.Li
|
||||||
|
func UpgradeTLS(conn net.Conn, serverName string, timeout time.Duration) (*tls.Conn, error) {
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||||
|
tlsConn := tls.Client(conn, &tls.Config{
|
||||||
|
ServerName: serverName,
|
||||||
|
InsecureSkipVerify: true, // RDP 常见自签证书,后续可落库校验指纹 // Logan.Li
|
||||||
|
MinVersion: tls.VersionTLS10,
|
||||||
|
MaxVersion: tls.VersionTLS12,
|
||||||
|
})
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = tlsConn.SetDeadline(time.Time{})
|
||||||
|
return tlsConn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSCipherSuiteName 返回 TLS cipher suite 名称,旧版本未命名时回退十六进制 // Logan.Li
|
||||||
|
func TLSCipherSuiteName(id uint16) string {
|
||||||
|
for _, suite := range tls.CipherSuites() {
|
||||||
|
if suite.ID == id {
|
||||||
|
return suite.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, suite := range tls.InsecureCipherSuites() {
|
||||||
|
if suite.ID == id {
|
||||||
|
return suite.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("0x%04x", id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tpktVersion = 3
|
||||||
|
tpktHeaderSize = 4
|
||||||
|
maxTPKTSize = 64 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteTPKT 写入 TPKT 包头与负载,RDP 初始握手基于 TPKT 承载 // Logan.Li
|
||||||
|
func WriteTPKT(w io.Writer, payload []byte) error {
|
||||||
|
total := len(payload) + tpktHeaderSize
|
||||||
|
if total > maxTPKTSize {
|
||||||
|
return fmt.Errorf("TPKT payload too large: %d", len(payload))
|
||||||
|
}
|
||||||
|
header := []byte{tpktVersion, 0, 0, 0}
|
||||||
|
binary.BigEndian.PutUint16(header[2:4], uint16(total))
|
||||||
|
if _, err := w.Write(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := w.Write(payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadTPKT 读取一个完整 TPKT 包并返回负载 // Logan.Li
|
||||||
|
func ReadTPKT(r io.Reader) ([]byte, error) {
|
||||||
|
header := make([]byte, tpktHeaderSize)
|
||||||
|
if _, err := io.ReadFull(r, header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if header[0] != tpktVersion {
|
||||||
|
return nil, fmt.Errorf("invalid TPKT version: %d", header[0])
|
||||||
|
}
|
||||||
|
total := int(binary.BigEndian.Uint16(header[2:4]))
|
||||||
|
if total < tpktHeaderSize || total > maxTPKTSize {
|
||||||
|
return nil, fmt.Errorf("invalid TPKT length: %d", total)
|
||||||
|
}
|
||||||
|
payload := make([]byte, total-tpktHeaderSize)
|
||||||
|
if _, err := io.ReadFull(r, payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRaw 直接写入已封装的 TPKT/FastPath 帧,供会话层发送输入事件 // Logan.Li
|
||||||
|
func WriteRaw(w io.Writer, frame []byte) error {
|
||||||
|
_, err := w.Write(frame)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
x224TypeConnectionRequest = 0xE0
|
||||||
|
x224TypeConnectionConfirm = 0xD0
|
||||||
|
x224TypeData = 0xF0
|
||||||
|
|
||||||
|
negTypeRequest = 0x01
|
||||||
|
negTypeResponse = 0x02
|
||||||
|
negTypeFailure = 0x03
|
||||||
|
|
||||||
|
protocolSSL = 0x00000001
|
||||||
|
protocolCredSSP = 0x00000002
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProtocolStandard uint32 = 0
|
||||||
|
ProtocolTLS uint32 = protocolSSL
|
||||||
|
ProtocolCredSSP uint32 = protocolCredSSP
|
||||||
|
)
|
||||||
|
|
||||||
|
// NegotiationResult 是 X.224 阶段的 RDP 安全协议协商结果 // Logan.Li
|
||||||
|
type NegotiationResult struct {
|
||||||
|
SelectedProtocol uint32
|
||||||
|
FailureCode uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r NegotiationResult) ProtocolName() string {
|
||||||
|
switch r.SelectedProtocol {
|
||||||
|
case ProtocolCredSSP:
|
||||||
|
return "CredSSP/NLA"
|
||||||
|
case ProtocolTLS:
|
||||||
|
return "TLS"
|
||||||
|
case ProtocolStandard:
|
||||||
|
return "Standard RDP"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("unknown(0x%x)", r.SelectedProtocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConnectionRequest 构造 X.224 Connection Request,并请求 NLA/TLS 协商 // Logan.Li
|
||||||
|
func BuildConnectionRequest() []byte {
|
||||||
|
return BuildConnectionRequestWithCookie("aiops")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConnectionRequestWithCookie 按 MS-RDPBCGR 2.2.1.1.1 构造包含 mstshash 的请求 // Logan.Li
|
||||||
|
func BuildConnectionRequestWithCookie(username string) []byte {
|
||||||
|
if username == "" {
|
||||||
|
username = "aiops"
|
||||||
|
}
|
||||||
|
if len(username) > 9 {
|
||||||
|
username = username[:9]
|
||||||
|
}
|
||||||
|
cookie := []byte("Cookie: mstshash=" + username + "\r\n")
|
||||||
|
neg := make([]byte, 8)
|
||||||
|
neg[0] = negTypeRequest
|
||||||
|
neg[1] = 0
|
||||||
|
binary.LittleEndian.PutUint16(neg[2:4], uint16(len(neg)))
|
||||||
|
binary.LittleEndian.PutUint32(neg[4:8], protocolSSL|protocolCredSSP)
|
||||||
|
|
||||||
|
length := 7 + len(cookie) + len(neg)
|
||||||
|
pdu := make([]byte, 0, length)
|
||||||
|
pdu = append(pdu, byte(length-1))
|
||||||
|
pdu = append(pdu, x224TypeConnectionRequest)
|
||||||
|
pdu = append(pdu, 0x00, 0x00) // dst-ref // Logan.Li
|
||||||
|
pdu = append(pdu, 0x00, 0x00) // src-ref // Logan.Li
|
||||||
|
pdu = append(pdu, 0x00) // class/options // Logan.Li
|
||||||
|
pdu = append(pdu, cookie...)
|
||||||
|
pdu = append(pdu, neg...)
|
||||||
|
return pdu
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConnectionConfirm 解析 X.224 Connection Confirm 与 RDP Negotiation Response // Logan.Li
|
||||||
|
func ParseConnectionConfirm(payload []byte) (NegotiationResult, error) {
|
||||||
|
if len(payload) < 7 {
|
||||||
|
return NegotiationResult{}, fmt.Errorf("short X.224 confirm: %d", len(payload))
|
||||||
|
}
|
||||||
|
if payload[1] != x224TypeConnectionConfirm {
|
||||||
|
return NegotiationResult{}, fmt.Errorf("unexpected X.224 pdu type: 0x%x", payload[1])
|
||||||
|
}
|
||||||
|
for i := 7; i+8 <= len(payload); i++ {
|
||||||
|
switch payload[i] {
|
||||||
|
case negTypeResponse:
|
||||||
|
return NegotiationResult{SelectedProtocol: binary.LittleEndian.Uint32(payload[i+4 : i+8])}, nil
|
||||||
|
case negTypeFailure:
|
||||||
|
return NegotiationResult{FailureCode: binary.LittleEndian.Uint32(payload[i+4 : i+8])}, fmt.Errorf("RDP negotiation failure: 0x%x", binary.LittleEndian.Uint32(payload[i+4:i+8]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NegotiationResult{SelectedProtocol: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteX224Data 写入 X.224 Data TPDU,NLA 后的 MCS/GCC 数据仍经由该层承载 // Logan.Li
|
||||||
|
func WriteX224Data(w io.Writer, payload []byte) error {
|
||||||
|
data := make([]byte, 0, len(payload)+3)
|
||||||
|
data = append(data, 0x02, x224TypeData, 0x80)
|
||||||
|
data = append(data, payload...)
|
||||||
|
return WriteTPKT(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadX224Data 读取并剥离 X.224 Data TPDU 头,返回上层 MCS 负载 // Logan.Li
|
||||||
|
func ReadX224Data(r io.Reader) ([]byte, error) {
|
||||||
|
payload, err := ReadTPKT(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(payload) < 3 {
|
||||||
|
return nil, fmt.Errorf("X.224 data too short: %d", len(payload))
|
||||||
|
}
|
||||||
|
if payload[1] != x224TypeData {
|
||||||
|
return nil, fmt.Errorf("unexpected X.224 type: 0x%x", payload[1])
|
||||||
|
}
|
||||||
|
return payload[3:], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package rdpfilestage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item 暂存文件条目 // Logan.Li
|
||||||
|
type Item struct {
|
||||||
|
Name string
|
||||||
|
Data []byte
|
||||||
|
Expire time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var store sync.Map
|
||||||
|
|
||||||
|
// Put 暂存文件并返回一次性 token(默认 30 分钟有效)// Logan.Li
|
||||||
|
func Put(name string, data []byte, ttl time.Duration) string {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 30 * time.Minute
|
||||||
|
}
|
||||||
|
token := uuid.NewString()
|
||||||
|
store.Store(token, Item{Name: name, Data: data, Expire: time.Now().Add(ttl)})
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 读取暂存文件 // Logan.Li
|
||||||
|
func Get(token string) (Item, bool) {
|
||||||
|
raw, ok := store.Load(token)
|
||||||
|
if !ok {
|
||||||
|
return Item{}, false
|
||||||
|
}
|
||||||
|
item := raw.(Item)
|
||||||
|
if time.Now().After(item.Expire) {
|
||||||
|
store.Delete(token)
|
||||||
|
return Item{}, false
|
||||||
|
}
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
"ezops/pkg/auditcleaner"
|
"ezops/pkg/auditcleaner"
|
||||||
|
"ezops/pkg/billingsync"
|
||||||
"ezops/pkg/database"
|
"ezops/pkg/database"
|
||||||
snmppkg "ezops/pkg/snmp"
|
snmppkg "ezops/pkg/snmp"
|
||||||
|
|
||||||
@@ -66,6 +67,11 @@ var (
|
|||||||
Description: "清理 7 天前的 SNMP 采集指标历史,防止表膨胀",
|
Description: "清理 7 天前的 SNMP 采集指标历史,防止表膨胀",
|
||||||
Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true,
|
Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Code: "cloud_billing_sync", Name: "云账单同步",
|
||||||
|
Description: "从云账号 AK 拉取昨日账单(按产品+地域),写入云账单管理;当前支持阿里云",
|
||||||
|
Schedule: "每天 10:30", Category: "finops", Runnable: true, Enabled: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -195,6 +201,14 @@ func RunManual(gdb *gorm.DB, driver, code string) (Item, error) {
|
|||||||
r := gdb.Exec(sql)
|
r := gdb.Exec(sql)
|
||||||
err = r.Error
|
err = r.Error
|
||||||
msg = fmt.Sprintf("清理 %d 条 SNMP 指标(保留 %d 天)", r.RowsAffected, snmppkg.MetricsRetentionDays)
|
msg = fmt.Sprintf("清理 %d 条 SNMP 指标(保留 %d 天)", r.RowsAffected, snmppkg.MetricsRetentionDays)
|
||||||
|
case "cloud_billing_sync":
|
||||||
|
result := billingsync.SyncDate(gdb, billingsync.Yesterday(), nil)
|
||||||
|
msg = billingsync.SummaryMessage(result)
|
||||||
|
if result.Accounts == 0 {
|
||||||
|
err = fmt.Errorf("没有可同步的活跃云账号")
|
||||||
|
} else if result.Created == 0 && result.Updated == 0 && len(result.Errors) > 0 {
|
||||||
|
err = fmt.Errorf("%s", result.Errors[0])
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return Item{}, fmt.Errorf("任务 %s 未实现手动执行", code)
|
return Item{}, fmt.Errorf("任务 %s 未实现手动执行", code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package winsmb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hirochachacha/go-smb2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 虚拟 Download 盘在 Windows 上的相对路径(挂载 C$ 共享)// Logan.Li
|
||||||
|
const (
|
||||||
|
AdminShare = "C$"
|
||||||
|
DownloadRelDir = `Users\Public\AIOpsShare\Download`
|
||||||
|
DownloadFullPath = `C:\Users\Public\AIOpsShare\Download`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auth SMB 管理员凭据 // Logan.Li
|
||||||
|
type Auth struct {
|
||||||
|
Host string
|
||||||
|
Domain string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEntry 目录项 // Logan.Li
|
||||||
|
type FileEntry struct {
|
||||||
|
Name string
|
||||||
|
Size int64
|
||||||
|
IsDir bool
|
||||||
|
ModTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFileName(name string) string {
|
||||||
|
base := path.Base(strings.ReplaceAll(name, `\`, "/"))
|
||||||
|
base = strings.TrimSpace(base)
|
||||||
|
if base == "" || base == "." || base == ".." {
|
||||||
|
return "upload.bin"
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Auth) dial() (*smb2.Share, func(), error) {
|
||||||
|
if strings.TrimSpace(a.Host) == "" {
|
||||||
|
return nil, nil, fmt.Errorf("SMB 主机为空")
|
||||||
|
}
|
||||||
|
d := &smb2.Dialer{
|
||||||
|
Initiator: &smb2.NTLMInitiator{
|
||||||
|
User: a.Username,
|
||||||
|
Password: a.Password,
|
||||||
|
Domain: a.Domain,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tcpConn, err := net.DialTimeout("tcp", net.JoinHostPort(a.Host, "445"), 15*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("SMB 连接失败(请确认 445 端口可达且账号有管理员权限): %w", err)
|
||||||
|
}
|
||||||
|
session, err := d.DialContext(context.Background(), tcpConn)
|
||||||
|
if err != nil {
|
||||||
|
tcpConn.Close()
|
||||||
|
return nil, nil, fmt.Errorf("SMB 认证失败: %w", err)
|
||||||
|
}
|
||||||
|
share, err := session.Mount(AdminShare)
|
||||||
|
if err != nil {
|
||||||
|
_ = session.Logoff()
|
||||||
|
return nil, nil, fmt.Errorf("挂载 %s 失败: %w", AdminShare, err)
|
||||||
|
}
|
||||||
|
cleanup := func() {
|
||||||
|
_ = share.Umount()
|
||||||
|
_ = session.Logoff()
|
||||||
|
}
|
||||||
|
return share, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDownloadDir 创建虚拟 Download 目录 // Logan.Li
|
||||||
|
func EnsureDownloadDir(auth Auth) error {
|
||||||
|
share, closeFn, err := auth.dial()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeFn()
|
||||||
|
cur := ""
|
||||||
|
for _, part := range strings.Split(DownloadRelDir, `\`) {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur == "" {
|
||||||
|
cur = part
|
||||||
|
} else {
|
||||||
|
cur = cur + `\` + part
|
||||||
|
}
|
||||||
|
_ = share.Mkdir(cur, 0755)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteFilePath(fileName string) string {
|
||||||
|
return DownloadRelDir + `\` + sanitizeFileName(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload 上传文件到虚拟 Download 盘 // Logan.Li
|
||||||
|
func Upload(auth Auth, fileName string, data []byte) error {
|
||||||
|
if err := EnsureDownloadDir(auth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
share, closeFn, err := auth.dial()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeFn()
|
||||||
|
target := remoteFilePath(fileName)
|
||||||
|
f, err := share.Create(target)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建远端文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("写入远端文件失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download 从虚拟 Download 盘读取文件 // Logan.Li
|
||||||
|
func Download(auth Auth, fileName string) ([]byte, error) {
|
||||||
|
share, closeFn, err := auth.dial()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer closeFn()
|
||||||
|
target := remoteFilePath(fileName)
|
||||||
|
f, err := share.Open(target)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("文件不存在: %s", sanitizeFileName(fileName))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("打开远端文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return io.ReadAll(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDownload 列出虚拟 Download 盘文件 // Logan.Li
|
||||||
|
func ListDownload(auth Auth) ([]FileEntry, error) {
|
||||||
|
if err := EnsureDownloadDir(auth); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
share, closeFn, err := auth.dial()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer closeFn()
|
||||||
|
entries, err := share.ReadDir(DownloadRelDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取目录失败: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]FileEntry, 0, len(entries))
|
||||||
|
for _, ent := range entries {
|
||||||
|
out = append(out, FileEntry{
|
||||||
|
Name: ent.Name(),
|
||||||
|
Size: ent.Size(),
|
||||||
|
IsDir: ent.IsDir(),
|
||||||
|
ModTime: ent.ModTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -23,12 +23,13 @@ import (
|
|||||||
|
|
||||||
// Connection types — identifies which feature a WebSocket serves.
|
// Connection types — identifies which feature a WebSocket serves.
|
||||||
const (
|
const (
|
||||||
TypeTerminal = "terminal" // WebShell SSH terminal
|
TypeTerminal = "terminal" // WebShell SSH terminal
|
||||||
TypeKubectl = "kubectl" // kubectl command terminal
|
TypeRDP = "rdp" // RDP 图形会话 // Logan.Li
|
||||||
TypePodExec = "pod_exec" // K8s pod exec terminal
|
TypeKubectl = "kubectl" // kubectl command terminal
|
||||||
TypeAudit = "audit" // Audit real-time stream (live monitor)
|
TypePodExec = "pod_exec" // K8s pod exec terminal
|
||||||
TypeMetrics = "metrics" // Dashboard metrics push
|
TypeAudit = "audit" // Audit real-time stream (live monitor)
|
||||||
TypePipeline = "pipeline" // Pipeline run log stream
|
TypeMetrics = "metrics" // Dashboard metrics push
|
||||||
|
TypePipeline = "pipeline" // Pipeline run log stream
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config tunes the manager behavior.
|
// Config tunes the manager behavior.
|
||||||
@@ -88,8 +89,8 @@ type WSManager struct {
|
|||||||
conns sync.Map // id → *ConnInfo
|
conns sync.Map // id → *ConnInfo
|
||||||
next uint64 // atomic counter for connection IDs
|
next uint64 // atomic counter for connection IDs
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a WSManager with the given config.
|
// New creates a WSManager with the given config.
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type agentStaticPaths struct {
|
||||||
|
binDir string
|
||||||
|
deployDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
var agentBinaryFiles = map[string]string{
|
||||||
|
"linux-amd64": "aiops-agent-linux-amd64",
|
||||||
|
"linux-arm64": "aiops-agent-linux-arm64",
|
||||||
|
"darwin-arm64": "aiops-agent-darwin-arm64",
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerAgentDownloads 注册 Agent 安装脚本和二进制下载路由,避免维护 backend/agent 重复目录。 // Logan.Li
|
||||||
|
func registerAgentDownloads(r *gin.Engine, log *zap.Logger) {
|
||||||
|
paths := resolveAgentStaticPaths(log)
|
||||||
|
|
||||||
|
r.GET("/agent/arch", func(c *gin.Context) {
|
||||||
|
base := requestBaseURL(c)
|
||||||
|
downloads := availableAgentDownloads(paths.binDir)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"downloads": downloads,
|
||||||
|
"install": fmt.Sprintf("curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token <token>", base, base),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.GET("/agent/install.sh", func(c *gin.Context) {
|
||||||
|
serveAgentStaticFile(c, paths.deployDir, "install.sh")
|
||||||
|
})
|
||||||
|
for _, fileName := range agentBinaryFiles {
|
||||||
|
name := fileName
|
||||||
|
r.GET("/agent/"+name, func(c *gin.Context) {
|
||||||
|
serveAgentStaticFile(c, paths.binDir, name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
r.GET("/agent/bin/:file", func(c *gin.Context) {
|
||||||
|
serveAgentStaticFile(c, paths.binDir, c.Param("file"))
|
||||||
|
})
|
||||||
|
r.GET("/api/agent/bin/:file", func(c *gin.Context) {
|
||||||
|
serveAgentStaticFile(c, paths.binDir, c.Param("file"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAgentStaticPaths 按部署形态寻找唯一 Agent 资源目录:容器优先,其次项目根目录。 // Logan.Li
|
||||||
|
func resolveAgentStaticPaths(log *zap.Logger) agentStaticPaths {
|
||||||
|
paths := agentStaticPaths{
|
||||||
|
binDir: firstExistingDir([]string{
|
||||||
|
"/app/agent/bin",
|
||||||
|
"./agent/bin",
|
||||||
|
"../agent/bin",
|
||||||
|
}),
|
||||||
|
deployDir: firstExistingDir([]string{
|
||||||
|
"/app/agent/deploy",
|
||||||
|
"./agent/deploy",
|
||||||
|
"../agent/deploy",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
if log != nil {
|
||||||
|
log.Info("Agent 静态资源目录已解析",
|
||||||
|
zap.String("bin_dir", paths.binDir),
|
||||||
|
zap.String("deploy_dir", paths.deployDir),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstExistingDir(candidates []string) string {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
info, err := os.Stat(candidate)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func availableAgentDownloads(binDir string) gin.H {
|
||||||
|
downloads := gin.H{}
|
||||||
|
for platform, fileName := range agentBinaryFiles {
|
||||||
|
if binDir == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(binDir, fileName)
|
||||||
|
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||||
|
downloads[platform] = "/agent/" + fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestBaseURL(c *gin.Context) string {
|
||||||
|
scheme := "http"
|
||||||
|
if c.Request.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
}
|
||||||
|
host := c.Request.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "localhost:8080"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", scheme, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveAgentStaticFile(c *gin.Context, dir string, fileName string) {
|
||||||
|
if dir == "" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent static directory not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(fileName, "/") || strings.Contains(fileName, "\\") || fileName == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(dir, fileName)
|
||||||
|
if info, err := os.Stat(fullPath); err != nil || info.IsDir() {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.File(fullPath)
|
||||||
|
}
|
||||||
+56
-58
@@ -1,17 +1,16 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"ezops/config"
|
||||||
"ezops/controller"
|
"ezops/controller"
|
||||||
"ezops/middleware"
|
"ezops/middleware"
|
||||||
"ezops/config"
|
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
"ezops/service"
|
|
||||||
ezredis "ezops/pkg/redis"
|
ezredis "ezops/pkg/redis"
|
||||||
"ezops/pkg/siteprobe"
|
"ezops/pkg/siteprobe"
|
||||||
"ezops/pkg/systask"
|
"ezops/pkg/systask"
|
||||||
"ezops/pkg/toolinstaller"
|
"ezops/pkg/toolinstaller"
|
||||||
"ezops/pkg/wshub"
|
"ezops/pkg/wshub"
|
||||||
"fmt"
|
"ezops/service"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -35,35 +34,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Agent 二进制下载 + 一键安装脚本(无需鉴权,方便 curl|bash)
|
// Agent 二进制下载 + 一键安装脚本(无需鉴权,方便 curl|bash)
|
||||||
r.Static("/agent/bin", "./agent/bin")
|
registerAgentDownloads(r, log)
|
||||||
r.StaticFile("/agent/install.sh", "./agent/deploy/install.sh")
|
|
||||||
r.StaticFile("/agent/aiops-agent-linux-amd64", "./agent/bin/aiops-agent-linux-amd64")
|
|
||||||
r.StaticFile("/agent/aiops-agent-linux-arm64", "./agent/bin/aiops-agent-linux-arm64")
|
|
||||||
r.StaticFile("/agent/aiops-agent-darwin-arm64", "./agent/bin/aiops-agent-darwin-arm64") // macOS Apple Silicon // Logan.Li
|
|
||||||
r.GET("/agent/arch", func(c *gin.Context) {
|
|
||||||
// 动态构建安装命令 URL(根据请求来源,兼容 nginx 反代)
|
|
||||||
scheme := "http"
|
|
||||||
if c.Request.TLS != nil {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
|
||||||
scheme = proto
|
|
||||||
}
|
|
||||||
host := c.Request.Host
|
|
||||||
if host == "" {
|
|
||||||
host = "localhost:8080"
|
|
||||||
}
|
|
||||||
base := fmt.Sprintf("%s://%s", scheme, host)
|
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"version": "1.0.0",
|
|
||||||
"downloads": gin.H{
|
|
||||||
"linux-amd64": "/agent/aiops-agent-linux-amd64",
|
|
||||||
"linux-arm64": "/agent/aiops-agent-linux-arm64",
|
|
||||||
"darwin-arm64": "/agent/aiops-agent-darwin-arm64",
|
|
||||||
},
|
|
||||||
"install": fmt.Sprintf("curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token <token>", base, base),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// === A3: 统一 WebSocket 连接管理器 === // Logan.Li
|
// === A3: 统一 WebSocket 连接管理器 === // Logan.Li
|
||||||
// 所有 WebSocket handler 共享同一个 WSManager(心跳、连接注册、监控、优雅关闭)
|
// 所有 WebSocket handler 共享同一个 WSManager(心跳、连接注册、监控、优雅关闭)
|
||||||
@@ -73,6 +44,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
auditHub := controller.NewAuditHub(wsMgr)
|
auditHub := controller.NewAuditHub(wsMgr)
|
||||||
webShellHandler := controller.NewWebShellHandler(db, auditHub, wsMgr)
|
webShellHandler := controller.NewWebShellHandler(db, auditHub, wsMgr)
|
||||||
r.GET("/ws/terminal", middleware.JWTAuth(&cfg.JWT, db), webShellHandler.Terminal)
|
r.GET("/ws/terminal", middleware.JWTAuth(&cfg.JWT, db), webShellHandler.Terminal)
|
||||||
|
rdpHandler := controller.NewRDPHandler(db, cfg.JWT.Secret, wsMgr)
|
||||||
|
r.GET("/ws/rdp", middleware.JWTAuth(&cfg.JWT, db), rdpHandler.Serve)
|
||||||
|
r.GET("/ws/rdp-audit/:sessionId", middleware.JWTAuth(&cfg.JWT, db), rdpHandler.ServeLive)
|
||||||
|
|
||||||
// WebSocket 审计实时流 — 在线会话实时查看 // Logan.Li
|
// WebSocket 审计实时流 — 在线会话实时查看 // Logan.Li
|
||||||
r.GET("/ws/audit/:sessionId", middleware.JWTAuth(&cfg.JWT, db), func(c *gin.Context) {
|
r.GET("/ws/audit/:sessionId", middleware.JWTAuth(&cfg.JWT, db), func(c *gin.Context) {
|
||||||
@@ -151,6 +125,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
api.POST("/auth/login", authHandler.Login)
|
api.POST("/auth/login", authHandler.Login)
|
||||||
api.POST("/auth/register", authHandler.Register)
|
api.POST("/auth/register", authHandler.Register)
|
||||||
|
|
||||||
|
// RDP 暂存文件拉取(token 鉴权,供 Windows iwr 回退下载)// Logan.Li
|
||||||
|
rdpFilePublic := controller.NewRDPFileHandler(db, cfg.JWT.Secret)
|
||||||
|
api.GET("/rdp-staged/:token", rdpFilePublic.ServeStaged)
|
||||||
|
|
||||||
// Webhook 触发路由(无需 JWT,由 Webhook Token 签名验证)
|
// Webhook 触发路由(无需 JWT,由 Webhook Token 签名验证)
|
||||||
pipelineHandler := controller.NewPipelineHandler(db, redisCache, ti)
|
pipelineHandler := controller.NewPipelineHandler(db, redisCache, ti)
|
||||||
webhookGroup := api.Group("/webhook")
|
webhookGroup := api.Group("/webhook")
|
||||||
@@ -272,18 +250,28 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
servers.POST("/batch-delete", serverHandlerV2.BatchDeleteServers)
|
servers.POST("/batch-delete", serverHandlerV2.BatchDeleteServers)
|
||||||
servers.POST("/batch", serverHandlerV2.BatchCreateServers)
|
servers.POST("/batch", serverHandlerV2.BatchCreateServers)
|
||||||
|
|
||||||
// Agent 远程安装 // Logan.Li
|
// Agent 远程安装 // Logan.Li
|
||||||
servers.POST("/:id/install-agent", agentInstallHandler.InstallAgent)
|
servers.POST("/:id/install-agent", agentInstallHandler.InstallAgent)
|
||||||
servers.POST("/install-agent", agentInstallHandler.BatchInstallAgent)
|
servers.POST("/install-agent", agentInstallHandler.BatchInstallAgent)
|
||||||
|
|
||||||
// SFTP 文件浏览 // Logan.Li
|
// SFTP 文件浏览 // Logan.Li
|
||||||
sftpHandler := controller.NewSFTPHandler(db)
|
sftpHandler := controller.NewSFTPHandler(db)
|
||||||
servers.GET("/:id/files", sftpHandler.ListFiles)
|
servers.GET("/:id/files", sftpHandler.ListFiles)
|
||||||
servers.GET("/:id/files/download", sftpHandler.DownloadFile)
|
servers.GET("/:id/files/download", sftpHandler.DownloadFile)
|
||||||
servers.POST("/:id/files/upload", sftpHandler.UploadFile)
|
servers.POST("/:id/files/upload", sftpHandler.UploadFile)
|
||||||
servers.DELETE("/:id/files", sftpHandler.DeleteFile)
|
servers.DELETE("/:id/files", sftpHandler.DeleteFile)
|
||||||
servers.POST("/:id/files/mkdir", sftpHandler.Mkdir)
|
servers.POST("/:id/files/mkdir", sftpHandler.Mkdir)
|
||||||
}
|
|
||||||
|
// RDP 虚拟 Download 盘(SMB 管理共享,一键上传下载)// Logan.Li
|
||||||
|
rdpFileHandler := controller.NewRDPFileHandler(db, cfg.JWT.Secret)
|
||||||
|
servers.GET("/:id/rdp-files", rdpFileHandler.ListFiles)
|
||||||
|
servers.POST("/:id/rdp-files/upload", rdpFileHandler.UploadFile)
|
||||||
|
servers.GET("/:id/rdp-files/download", rdpFileHandler.DownloadFile)
|
||||||
|
servers.POST("/:id/rdp-files/stage", rdpFileHandler.StageUpload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 云账单处理器(云账号同步账单路由共用) // Logan.Li
|
||||||
|
billingHandler := controller.NewBillingHandler(db)
|
||||||
|
|
||||||
// 云账号路由
|
// 云账号路由
|
||||||
cloudAccountHandler := controller.NewCloudAccountHandler(db)
|
cloudAccountHandler := controller.NewCloudAccountHandler(db)
|
||||||
@@ -295,11 +283,12 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
cloudAccounts.DELETE("/:id", cloudAccountHandler.DeleteCloudAccount)
|
cloudAccounts.DELETE("/:id", cloudAccountHandler.DeleteCloudAccount)
|
||||||
|
|
||||||
// 云同步路由 — 区域同步 / 主机发现 / 导入主机 // Logan.Li
|
// 云同步路由 — 区域同步 / 主机发现 / 导入主机 // Logan.Li
|
||||||
syncHandler := controller.NewCloudSyncHandler(db)
|
syncHandler := controller.NewCloudSyncHandler(db, cfg.JWT.Secret)
|
||||||
cloudAccounts.POST("/:id/sync-regions", syncHandler.SyncRegions)
|
cloudAccounts.POST("/:id/sync-regions", syncHandler.SyncRegions)
|
||||||
cloudAccounts.GET("/:id/regions", syncHandler.GetAccountRegions)
|
cloudAccounts.GET("/:id/regions", syncHandler.GetAccountRegions)
|
||||||
cloudAccounts.POST("/:id/discover-instances", syncHandler.DiscoverInstances)
|
cloudAccounts.POST("/:id/discover-instances", syncHandler.DiscoverInstances)
|
||||||
cloudAccounts.POST("/:id/import-instances", syncHandler.ImportInstances)
|
cloudAccounts.POST("/:id/import-instances", syncHandler.ImportInstances)
|
||||||
|
cloudAccounts.POST("/:id/sync-billing", billingHandler.SyncAccountBilling)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 域名路由
|
// 域名路由
|
||||||
@@ -334,7 +323,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 云账单路由
|
// 云账单路由
|
||||||
billingHandler := controller.NewBillingHandler(db)
|
|
||||||
billings := authorized.Group("/cloud-billings")
|
billings := authorized.Group("/cloud-billings")
|
||||||
{
|
{
|
||||||
billings.GET("", billingHandler.ListBillings)
|
billings.GET("", billingHandler.ListBillings)
|
||||||
@@ -343,6 +331,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
billings.DELETE("/:id", billingHandler.DeleteBilling)
|
billings.DELETE("/:id", billingHandler.DeleteBilling)
|
||||||
billings.GET("/stats", billingHandler.GetBillingStats)
|
billings.GET("/stats", billingHandler.GetBillingStats)
|
||||||
billings.GET("/providers", billingHandler.GetProviderOptions)
|
billings.GET("/providers", billingHandler.GetProviderOptions)
|
||||||
|
billings.POST("/sync", billingHandler.SyncYesterdayBillings)
|
||||||
}
|
}
|
||||||
billingAlerts := authorized.Group("/billing-alerts")
|
billingAlerts := authorized.Group("/billing-alerts")
|
||||||
{
|
{
|
||||||
@@ -376,15 +365,15 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
clusters.POST("", k8sHandler.CreateCluster)
|
clusters.POST("", k8sHandler.CreateCluster)
|
||||||
clusters.PUT("/:id", k8sHandler.UpdateCluster)
|
clusters.PUT("/:id", k8sHandler.UpdateCluster)
|
||||||
clusters.DELETE("/:id", k8sHandler.DeleteCluster)
|
clusters.DELETE("/:id", k8sHandler.DeleteCluster)
|
||||||
clusters.POST("/:id/sync", k8sHandler.SyncCluster) // 同步集群资源 // Logan.Li
|
clusters.POST("/:id/sync", k8sHandler.SyncCluster) // 同步集群资源 // Logan.Li
|
||||||
clusters.GET("/:id/nodes", k8sHandler.ListNodes) // Deprecated: use GET /kubernetes/:clusterCode/nodes
|
clusters.GET("/:id/nodes", k8sHandler.ListNodes) // Deprecated: use GET /kubernetes/:clusterCode/nodes
|
||||||
clusters.GET("/:id/nodes/:name", k8sHandler.GetNodeDetail) // M10 节点详情 // Logan.Li
|
clusters.GET("/:id/nodes/:name", k8sHandler.GetNodeDetail) // M10 节点详情 // Logan.Li
|
||||||
clusters.GET("/:id/pods", k8sHandler.ListPods) // Deprecated: use GET /kubernetes/:clusterCode/pods
|
clusters.GET("/:id/pods", k8sHandler.ListPods) // Deprecated: use GET /kubernetes/:clusterCode/pods
|
||||||
clusters.GET("/:id/deployments", k8sHandler.ListDeployments) // Deprecated: use GET /kubernetes/:clusterCode/deployments
|
clusters.GET("/:id/deployments", k8sHandler.ListDeployments) // Deprecated: use GET /kubernetes/:clusterCode/deployments
|
||||||
clusters.GET("/:id/services", k8sHandler.ListServices) // Deprecated: use GET /kubernetes/:clusterCode/services
|
clusters.GET("/:id/services", k8sHandler.ListServices) // Deprecated: use GET /kubernetes/:clusterCode/services
|
||||||
clusters.GET("/:id/ingresses", k8sHandler.ListIngresses) // Deprecated: use GET /kubernetes/:clusterCode/ingresses
|
clusters.GET("/:id/ingresses", k8sHandler.ListIngresses) // Deprecated: use GET /kubernetes/:clusterCode/ingresses
|
||||||
clusters.GET("/:id/topology", k8sHandler.GetTopology) // 获取拓扑布局 // Logan.Li
|
clusters.GET("/:id/topology", k8sHandler.GetTopology) // 获取拓扑布局 // Logan.Li
|
||||||
clusters.PUT("/:id/topology", k8sHandler.SaveTopology) // 保存拓扑布局 // Logan.Li
|
clusters.PUT("/:id/topology", k8sHandler.SaveTopology) // 保存拓扑布局 // Logan.Li
|
||||||
|
|
||||||
// Pod 日志 // Logan.Li
|
// Pod 日志 // Logan.Li
|
||||||
clusters.GET("/:id/namespaces/:namespace/pods/:podName/logs", k8sHandler.GetPodLogs)
|
clusters.GET("/:id/namespaces/:namespace/pods/:podName/logs", k8sHandler.GetPodLogs)
|
||||||
@@ -681,7 +670,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
releases.GET("", pipelineHandler.ListReleases)
|
releases.GET("", pipelineHandler.ListReleases)
|
||||||
releases.POST("", pipelineHandler.CreateRelease)
|
releases.POST("", pipelineHandler.CreateRelease)
|
||||||
releases.POST("/:id/approve", pipelineHandler.ApproveRelease)
|
releases.POST("/:id/approve", pipelineHandler.ApproveRelease)
|
||||||
releases.POST("/:id/reject", pipelineHandler.RejectRelease) // 驳回发布 // Logan.Li
|
releases.POST("/:id/reject", pipelineHandler.RejectRelease) // 驳回发布 // Logan.Li
|
||||||
releases.POST("/:id/deploy", pipelineHandler.DeployRelease)
|
releases.POST("/:id/deploy", pipelineHandler.DeployRelease)
|
||||||
releases.POST("/:id/rollback", pipelineHandler.RollbackRelease)
|
releases.POST("/:id/rollback", pipelineHandler.RollbackRelease)
|
||||||
}
|
}
|
||||||
@@ -870,6 +859,18 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
ts.GET("/:id/stream", terminalSessionHandler.StreamSession)
|
ts.GET("/:id/stream", terminalSessionHandler.StreamSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Windows RDP 图形会话审计路由(只做录屏帧回放,不做命令审计) // Logan.Li
|
||||||
|
rdpSessionHandler := controller.NewRDPSessionHandler(db, rdpHandler.KillSession)
|
||||||
|
rs := authorized.Group("/rdp-sessions")
|
||||||
|
{
|
||||||
|
rs.GET("", rdpSessionHandler.ListRDPSessions)
|
||||||
|
rs.GET("/:id", rdpSessionHandler.GetRDPSession)
|
||||||
|
rs.GET("/:id/record", rdpSessionHandler.GetRDPRecord)
|
||||||
|
rs.POST("/:id/kill", rdpSessionHandler.KillRDPSession)
|
||||||
|
rs.GET("/:id/download", rdpSessionHandler.DownloadRDPRecord)
|
||||||
|
rs.POST("/:id/replay-view", rdpSessionHandler.LogRDPReplayView)
|
||||||
|
}
|
||||||
|
|
||||||
// 会话命令审计路由(JumpServer 式命令审计) // Logan.Li
|
// 会话命令审计路由(JumpServer 式命令审计) // Logan.Li
|
||||||
scHandler := controller.NewSessionCommandHandler(db)
|
scHandler := controller.NewSessionCommandHandler(db)
|
||||||
sc := authorized.Group("/session-commands")
|
sc := authorized.Group("/session-commands")
|
||||||
@@ -938,9 +939,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
agentQuery.PUT("/port-policies/:hostname", agentHandler.SavePortPolicy)
|
agentQuery.PUT("/port-policies/:hostname", agentHandler.SavePortPolicy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent 二进制下载(无需鉴权,Agent 升级时使用)
|
|
||||||
r.Static("/api/agent/bin", "/app/agent/bin")
|
|
||||||
|
|
||||||
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
|
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
|
||||||
llmHandler := controller.NewLLMHandler(db)
|
llmHandler := controller.NewLLMHandler(db)
|
||||||
aiopsGroup := authorized.Group("/aiops")
|
aiopsGroup := authorized.Group("/aiops")
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ func SeedData(db *gorm.DB) {
|
|||||||
}`,
|
}`,
|
||||||
FlowConfig: `[{"name":"直接主管","approver_role":"team_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"运维执行","approver_role":"ops_manager","step_type":"execute"}]`,
|
FlowConfig: `[{"name":"直接主管","approver_role":"team_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"运维执行","approver_role":"ops_manager","step_type":"execute"}]`,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "应用部署申请",
|
Name: "应用部署申请",
|
||||||
Code: "app_deploy",
|
Code: "app_deploy",
|
||||||
Description: "申请新应用部署,包含 Git 地址、端口、Nacos 等配置信息",
|
Description: "申请新应用部署,包含 Git 地址、端口、Nacos 等配置信息",
|
||||||
@@ -109,7 +109,7 @@ func SeedData(db *gorm.DB) {
|
|||||||
FlowConfig: `[{"name":"技术负责人","approver_role":"tech_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"部署执行","approver_role":"ops_manager","step_type":"execute"}]`,
|
FlowConfig: `[{"name":"技术负责人","approver_role":"tech_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"部署执行","approver_role":"ops_manager","step_type":"execute"}]`,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "权限申请",
|
Name: "权限申请",
|
||||||
Code: "permission_request",
|
Code: "permission_request",
|
||||||
Description: "申请堡垒机、生产VPN、日志查询、数据查询等运维权限",
|
Description: "申请堡垒机、生产VPN、日志查询、数据查询等运维权限",
|
||||||
@@ -127,7 +127,7 @@ func SeedData(db *gorm.DB) {
|
|||||||
}`,
|
}`,
|
||||||
FlowConfig: `[{"name":"直接主管","approver_role":"team_lead","step_type":"audit"},{"name":"安全负责人","approver_role":"security_lead","step_type":"audit"},{"name":"权限开通","approver_role":"ops_manager","step_type":"execute"}]`,
|
FlowConfig: `[{"name":"直接主管","approver_role":"team_lead","step_type":"audit"},{"name":"安全负责人","approver_role":"security_lead","step_type":"audit"},{"name":"权限开通","approver_role":"ops_manager","step_type":"execute"}]`,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := db.Create(&templates).Error; err != nil {
|
if err := db.Create(&templates).Error; err != nil {
|
||||||
zap.L().Warn("failed to seed approval templates", zap.Error(err))
|
zap.L().Warn("failed to seed approval templates", zap.Error(err))
|
||||||
@@ -171,7 +171,7 @@ func SeedData(db *gorm.DB) {
|
|||||||
// AIOps
|
// AIOps
|
||||||
{Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, Status: 1},
|
{Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, Status: 1},
|
||||||
{Name: "告警分析", Code: "aiops:analysis", Type: "menu", Sort: 61, Status: 1},
|
{Name: "告警分析", Code: "aiops:analysis", Type: "menu", Sort: 61, Status: 1},
|
||||||
{Name: "容量预测", Code: "aiops:forecast", Type: "menu", Sort: 62, Status: 1},
|
{Name: "预测分析", Code: "aiops:forecast", Type: "menu", Sort: 62, Status: 1},
|
||||||
{Name: "Agent CLI", Code: "aiops:agent-cli", Type: "menu", Sort: 63, Status: 1},
|
{Name: "Agent CLI", Code: "aiops:agent-cli", Type: "menu", Sort: 63, Status: 1},
|
||||||
{Name: "大模型设置", Code: "aiops:llm-config", Type: "menu", Sort: 64, Status: 1},
|
{Name: "大模型设置", Code: "aiops:llm-config", Type: "menu", Sort: 64, Status: 1},
|
||||||
// 资源申请
|
// 资源申请
|
||||||
@@ -193,6 +193,8 @@ func SeedData(db *gorm.DB) {
|
|||||||
db.Create(&perms)
|
db.Create(&perms)
|
||||||
zap.L().Info("seeded default permissions")
|
zap.L().Info("seeded default permissions")
|
||||||
}
|
}
|
||||||
|
// 兼容旧库菜单命名,将容量预测统一更名为预测分析。 // Logan.Li
|
||||||
|
db.Model(&model.Permission{}).Where("code = ?", "aiops:forecast").Update("name", "预测分析")
|
||||||
|
|
||||||
// 5. 创建默认角色(如果不存在)
|
// 5. 创建默认角色(如果不存在)
|
||||||
var roleCount int64
|
var roleCount int64
|
||||||
|
|||||||
Binary file not shown.
+146
-7
@@ -9,9 +9,9 @@ import (
|
|||||||
|
|
||||||
"ezops/dto"
|
"ezops/dto"
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
"ezops/repository"
|
|
||||||
"ezops/pkg/crypto"
|
"ezops/pkg/crypto"
|
||||||
ezredis "ezops/pkg/redis"
|
ezredis "ezops/pkg/redis"
|
||||||
|
"ezops/repository"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -60,6 +60,9 @@ func (s *ServerService) CreateServer(req *dto.CreateServerRequest) (*model.Serve
|
|||||||
Port: req.Port,
|
Port: req.Port,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
AuthType: req.AuthType,
|
AuthType: req.AuthType,
|
||||||
|
OSType: req.OSType,
|
||||||
|
Protocol: req.Protocol,
|
||||||
|
RDPPort: req.RDPPort,
|
||||||
OS: req.OS,
|
OS: req.OS,
|
||||||
OSVersion: req.OSVersion,
|
OSVersion: req.OSVersion,
|
||||||
Arch: req.Arch,
|
Arch: req.Arch,
|
||||||
@@ -80,6 +83,18 @@ func (s *ServerService) CreateServer(req *dto.CreateServerRequest) (*model.Serve
|
|||||||
if req.AuthType == "" {
|
if req.AuthType == "" {
|
||||||
srv.AuthType = "password"
|
srv.AuthType = "password"
|
||||||
}
|
}
|
||||||
|
if srv.OSType == "" {
|
||||||
|
srv.OSType = "linux"
|
||||||
|
}
|
||||||
|
if srv.Protocol == "" {
|
||||||
|
srv.Protocol = "ssh"
|
||||||
|
}
|
||||||
|
if srv.Protocol == "rdp" {
|
||||||
|
srv.OSType = "windows"
|
||||||
|
if srv.RDPPort == 0 {
|
||||||
|
srv.RDPPort = 3389
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加密密码和 SSH 密钥
|
// 加密密码和 SSH 密钥
|
||||||
if req.Password != "" {
|
if req.Password != "" {
|
||||||
@@ -96,6 +111,11 @@ func (s *ServerService) CreateServer(req *dto.CreateServerRequest) (*model.Serve
|
|||||||
}
|
}
|
||||||
srv.SSHKey = enc
|
srv.SSHKey = enc
|
||||||
}
|
}
|
||||||
|
if req.CredentialID != nil && *req.CredentialID > 0 {
|
||||||
|
if err := s.applyCredentialToServer(*req.CredentialID, srv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.repo.Create(srv); err != nil {
|
if err := s.repo.Create(srv); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -125,6 +145,15 @@ func (s *ServerService) UpdateServer(id uint, req *dto.UpdateServerRequest) erro
|
|||||||
if req.AuthType != nil {
|
if req.AuthType != nil {
|
||||||
updates["auth_type"] = *req.AuthType
|
updates["auth_type"] = *req.AuthType
|
||||||
}
|
}
|
||||||
|
if req.OSType != nil {
|
||||||
|
updates["os_type"] = *req.OSType
|
||||||
|
}
|
||||||
|
if req.Protocol != nil {
|
||||||
|
updates["protocol"] = *req.Protocol
|
||||||
|
}
|
||||||
|
if req.RDPPort != nil {
|
||||||
|
updates["rdp_port"] = *req.RDPPort
|
||||||
|
}
|
||||||
if req.OS != nil {
|
if req.OS != nil {
|
||||||
updates["os"] = *req.OS
|
updates["os"] = *req.OS
|
||||||
}
|
}
|
||||||
@@ -174,6 +203,23 @@ func (s *ServerService) UpdateServer(id uint, req *dto.UpdateServerRequest) erro
|
|||||||
}
|
}
|
||||||
updates["ssh_key"] = enc
|
updates["ssh_key"] = enc
|
||||||
}
|
}
|
||||||
|
if req.CredentialID != nil && *req.CredentialID > 0 {
|
||||||
|
srv, err := s.repo.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.applyCredentialToServer(*req.CredentialID, srv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates["username"] = srv.Username
|
||||||
|
updates["password"] = srv.Password
|
||||||
|
updates["ssh_key"] = srv.SSHKey
|
||||||
|
updates["auth_type"] = srv.AuthType
|
||||||
|
updates["port"] = srv.Port
|
||||||
|
updates["rdp_port"] = srv.RDPPort
|
||||||
|
updates["protocol"] = srv.Protocol
|
||||||
|
updates["os_type"] = srv.OSType
|
||||||
|
}
|
||||||
|
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -181,6 +227,67 @@ func (s *ServerService) UpdateServer(id uint, req *dto.UpdateServerRequest) erro
|
|||||||
return s.repo.UpdateFields(id, updates)
|
return s.repo.UpdateFields(id, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ServerService) applyCredentialToServer(credentialID uint, srv *model.Server) error {
|
||||||
|
cred, err := s.credentialByID(credentialID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plain, err := s.crypt.Decrypt(cred.Content)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("凭据解密失败: %w", err)
|
||||||
|
}
|
||||||
|
switch cred.SecretType {
|
||||||
|
case "ssh":
|
||||||
|
var sshc model.SSHSecretContent
|
||||||
|
if json.Unmarshal([]byte(plain), &sshc) == nil {
|
||||||
|
if sshc.SSHUser != "" {
|
||||||
|
srv.Username = sshc.SSHUser
|
||||||
|
}
|
||||||
|
if sshc.SSHPort > 0 {
|
||||||
|
srv.Port = sshc.SSHPort
|
||||||
|
}
|
||||||
|
if sshc.PrivateKey != "" {
|
||||||
|
srv.SSHKey = sshc.PrivateKey
|
||||||
|
srv.Password = ""
|
||||||
|
srv.AuthType = "key"
|
||||||
|
} else if sshc.Password != "" {
|
||||||
|
srv.Password = sshc.Password
|
||||||
|
srv.SSHKey = ""
|
||||||
|
srv.AuthType = "password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
srv.Protocol = "ssh"
|
||||||
|
if srv.OSType == "" {
|
||||||
|
srv.OSType = "linux"
|
||||||
|
}
|
||||||
|
case "rdp":
|
||||||
|
var rdpc model.RDPSecretContent
|
||||||
|
if err := json.Unmarshal([]byte(plain), &rdpc); err != nil {
|
||||||
|
return fmt.Errorf("RDP 凭据格式错误: %w", err)
|
||||||
|
}
|
||||||
|
srv.Username = rdpc.Username
|
||||||
|
srv.Password = plain
|
||||||
|
srv.SSHKey = ""
|
||||||
|
srv.AuthType = "password"
|
||||||
|
srv.Protocol = "rdp"
|
||||||
|
srv.OSType = "windows"
|
||||||
|
if srv.RDPPort == 0 {
|
||||||
|
srv.RDPPort = 3389
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("服务器仅支持关联 ssh/rdp 凭据")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServerService) credentialByID(id uint) (*model.Credential, error) {
|
||||||
|
var cred model.Credential
|
||||||
|
if err := s.repo.DB().First(&cred, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cred, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteServer 删除服务器
|
// DeleteServer 删除服务器
|
||||||
func (s *ServerService) DeleteServer(id uint) error {
|
func (s *ServerService) DeleteServer(id uint) error {
|
||||||
return s.repo.Delete(id)
|
return s.repo.Delete(id)
|
||||||
@@ -237,6 +344,19 @@ func (s *CredentialService) buildCredentialContent(req *dto.CreateCredentialRequ
|
|||||||
}
|
}
|
||||||
b, _ := json.Marshal(data)
|
b, _ := json.Marshal(data)
|
||||||
return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil
|
return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil
|
||||||
|
case "rdp":
|
||||||
|
security := req.RDPSecurity
|
||||||
|
if security == "" {
|
||||||
|
security = "nla"
|
||||||
|
}
|
||||||
|
data := map[string]string{
|
||||||
|
"username": req.RDPUsername,
|
||||||
|
"password": req.RDPPassword,
|
||||||
|
"domain": req.RDPDomain,
|
||||||
|
"security": security,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
return string(b), fmt.Sprintf("RDP %s", req.RDPUsername), nil
|
||||||
case "docker":
|
case "docker":
|
||||||
data := map[string]string{
|
data := map[string]string{
|
||||||
"server": req.DockerServer,
|
"server": req.DockerServer,
|
||||||
@@ -309,6 +429,25 @@ func (s *CredentialService) buildUpdateContent(req *dto.UpdateCredentialRequest,
|
|||||||
}
|
}
|
||||||
b, _ := json.Marshal(data)
|
b, _ := json.Marshal(data)
|
||||||
return string(b), "", nil
|
return string(b), "", nil
|
||||||
|
case "rdp":
|
||||||
|
data := map[string]string{}
|
||||||
|
if req.RDPUsername != nil {
|
||||||
|
data["username"] = *req.RDPUsername
|
||||||
|
}
|
||||||
|
if req.RDPPassword != nil {
|
||||||
|
data["password"] = *req.RDPPassword
|
||||||
|
}
|
||||||
|
if req.RDPDomain != nil {
|
||||||
|
data["domain"] = *req.RDPDomain
|
||||||
|
}
|
||||||
|
if req.RDPSecurity != nil {
|
||||||
|
data["security"] = *req.RDPSecurity
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
return string(b), "", nil
|
||||||
case "docker":
|
case "docker":
|
||||||
data := map[string]string{}
|
data := map[string]string{}
|
||||||
if req.DockerServer != nil {
|
if req.DockerServer != nil {
|
||||||
@@ -523,9 +662,9 @@ func toJSONString(v interface{}) string {
|
|||||||
// ━━━ Config Helper — 管理所有 Service 的依赖注入 ━━━━
|
// ━━━ Config Helper — 管理所有 Service 的依赖注入 ━━━━
|
||||||
|
|
||||||
type Services struct {
|
type Services struct {
|
||||||
Server *ServerService
|
Server *ServerService
|
||||||
Credential *CredentialService
|
Credential *CredentialService
|
||||||
AlertContact *AlertContactService
|
AlertContact *AlertContactService
|
||||||
AlertContactGroup *AlertContactGroupService
|
AlertContactGroup *AlertContactGroupService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,9 +673,9 @@ func NewServices(db *gorm.DB, cache ezredis.Cache, masterKey string) *Services {
|
|||||||
crypt := crypto.NewSecretBox(masterKey)
|
crypt := crypto.NewSecretBox(masterKey)
|
||||||
|
|
||||||
return &Services{
|
return &Services{
|
||||||
Server: NewServerService(repository.NewServerRepository(db), cache, crypt),
|
Server: NewServerService(repository.NewServerRepository(db), cache, crypt),
|
||||||
Credential: NewCredentialService(repository.NewCredentialRepository(db), crypt),
|
Credential: NewCredentialService(repository.NewCredentialRepository(db), crypt),
|
||||||
AlertContact: NewAlertContactService(repository.NewAlertContactRepository(db)),
|
AlertContact: NewAlertContactService(repository.NewAlertContactRepository(db)),
|
||||||
AlertContactGroup: NewAlertContactGroupService(repository.NewAlertContactGroupRepository(db)),
|
AlertContactGroup: NewAlertContactGroupService(repository.NewAlertContactGroupRepository(db)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,9 @@ services:
|
|||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
REDIS_PORT: 6379
|
REDIS_PORT: 6379
|
||||||
JWT_SECRET: ${JWT_SECRET:-aiops-jwt-secret-change-me}
|
JWT_SECRET: ${JWT_SECRET:-aiops-jwt-secret-change-me}
|
||||||
|
# RDP 使用自研协议栈,第三方 grdp 已移除;该变量保留兼容但后端不再读取。 // Logan.Li
|
||||||
|
RDP_ENGINE: ${RDP_ENGINE:-rdp}
|
||||||
|
RDP_DEBUG: ${RDP_DEBUG:-1}
|
||||||
volumes:
|
volumes:
|
||||||
- aiops_uploads:/app/uploads
|
- aiops_uploads:/app/uploads
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
| [API 接口](./api-reference.md) | 后端 API 接口文档 |
|
| [API 接口](./api-reference.md) | 后端 API 接口文档 |
|
||||||
| [数据库设计](./database-schema.md) | 数据模型和表结构 |
|
| [数据库设计](./database-schema.md) | 数据模型和表结构 |
|
||||||
| [开发日志](./dev-log.md) | 详细开发日志(按日期记录) |
|
| [开发日志](./dev-log.md) | 详细开发日志(按日期记录) |
|
||||||
|
| [Web RDP 远程桌面](./rdp.md) | 自研 RDP 协议栈、WebSocket 帧协议(归档:`/opt/rdp/`) |
|
||||||
| [Redis 缓存策略](./redis-caching-strategy.md) | Redis 缓存使用现状与优化方案 |
|
| [Redis 缓存策略](./redis-caching-strategy.md) | Redis 缓存使用现状与优化方案 |
|
||||||
|
|
||||||
## 设计文档
|
## 设计文档
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
| 日志告警 | AlertEngine 支持 `type=log` 规则,查询 Loki / ES 触发/恢复 |
|
| 日志告警 | AlertEngine 支持 `type=log` 规则,查询 Loki / ES 触发/恢复 |
|
||||||
| 远程执行 | 新增远程执行页(执行/上传),移除 ansible 命名 |
|
| 远程执行 | 新增远程执行页(执行/上传),移除 ansible 命名 |
|
||||||
| 大模型双协议 | OpenAI 兼容 + Anthropic Messages 协议接入 |
|
| 大模型双协议 | OpenAI 兼容 + Anthropic Messages 协议接入 |
|
||||||
|
| Web RDP 自研引擎 | 移除 grdp,SessionEngine 完整图形会话,见 [rdp.md](./rdp.md) |
|
||||||
|
|
||||||
## 部署速查
|
## 部署速查
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 前端视觉与页面规范
|
||||||
|
|
||||||
|
本文档约定 AIOps 控制台的页面结构、视觉基础和复用组件。新增页面优先使用这些规范,已有页面逐步迁移。
|
||||||
|
|
||||||
|
## 设计目标
|
||||||
|
|
||||||
|
- 页面打开即用,避免营销式大标题和多余说明。
|
||||||
|
- 信息密度适中,优先支持扫描、筛选、对比和批量操作。
|
||||||
|
- 主题感知,颜色优先使用 Tailwind 语义色和 `var(--dg-*)` 变量。
|
||||||
|
- 同类页面保持一致:列表、详情、弹窗、空态、加载态和错误态不重复造轮子。
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
标准业务页使用 `PageShell`:
|
||||||
|
|
||||||
|
1. `PageHeader`:标题、简短描述、主操作按钮。
|
||||||
|
2. `FilterBar`:搜索、筛选、刷新、批量操作。
|
||||||
|
3. 主内容区:表格、图表、详情或编辑器。
|
||||||
|
4. `PageSection`:页面内分组区域。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader title="服务器" description="管理主机资产和 Agent 状态" actions={...} />
|
||||||
|
<FilterBar left={searchInput} right={refreshButton} />
|
||||||
|
<PageSection>{table}</PageSection>
|
||||||
|
</PageShell>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 状态与数据展示
|
||||||
|
|
||||||
|
- 状态统一使用 `StatusBadge`,不要在页面中临时拼颜色。
|
||||||
|
- 指标卡片使用 `MetricCard`,用于概览数值、趋势和健康状态。
|
||||||
|
- 空态优先使用 `EmptyState`,需要明确下一步动作时传入 `action`。
|
||||||
|
|
||||||
|
常用状态:`success`、`warning`、`danger`、`info`、`neutral`、`running`、`offline`。
|
||||||
|
|
||||||
|
## 交互规范
|
||||||
|
|
||||||
|
- 删除、重置、停用等高危操作必须使用确认弹窗,不使用 `window.confirm()`。
|
||||||
|
- Dialog 必须包含 `DialogDescription`。
|
||||||
|
- 列表页操作按钮文案保持 2-4 字,图标按钮需要 tooltip。
|
||||||
|
- 加载中显示 skeleton 或 spinner,不能让页面无反馈。
|
||||||
|
|
||||||
|
## 代码约定
|
||||||
|
|
||||||
|
- 通用页面组件放在 `frontend/src/components/common/`。
|
||||||
|
- 基础原子组件继续放在 `frontend/src/components/ui/`。
|
||||||
|
- 页面组件使用 `PascalCase.tsx`,工具函数使用 `camelCase.ts`。
|
||||||
|
- 新增颜色进入主题变量,不在业务组件硬编码十六进制颜色。
|
||||||
@@ -1,5 +1,43 @@
|
|||||||
# AIOps 开发日志
|
# AIOps 开发日志
|
||||||
|
|
||||||
|
## 2026-07-03 Web RDP 自研图形引擎(移除 grdp)
|
||||||
|
|
||||||
|
### 完成项
|
||||||
|
|
||||||
|
| 项目 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 自研 SessionEngine | ✅ | `NewEngine()` → `SessionEngine`,完整 MCS + 位图 + 键鼠 |
|
||||||
|
| 移除 nakagami/grdp | ✅ | `go.mod` 无 grdp;`Dockerfile` 去掉 `third_party/` |
|
||||||
|
| 位图解压 | ✅ | `rle/` RLE + `mppc.go` MPPC + `bitmap_decode.go` PNG 编码 |
|
||||||
|
| FastPath 收发 | ✅ | `packet_io.go`、`fastpath_recv.go`、`input_fastpath.go` |
|
||||||
|
| 会话激活 | ✅ | FontMap 后 `SuppressOutput(ALLOW_DISPLAY_UPDATES)` |
|
||||||
|
| 文件传输 | ✅ | SMB 直传 + `rdpfilestage` 暂存 + `auto_exec` 回退 |
|
||||||
|
| 第三方备份 | ✅ | `/opt/rdp/third-party/`(grdp v0.8.7 + 参考实现) |
|
||||||
|
| 文档 | ✅ | `docs/rdp.md` + `/opt/rdp/CHANGELOG.md` |
|
||||||
|
|
||||||
|
### 新增 / 变更文件(`backend/pkg/rdp/`)
|
||||||
|
|
||||||
|
- `session_engine.go`、`session_connect.go`、`packet_io.go`
|
||||||
|
- `fastpath_recv.go`、`bitmap_decode.go`、`input_fastpath.go`、`mppc.go`
|
||||||
|
- `rle/`(`util.go`、`bpp1~4.go`、`entry.go`)
|
||||||
|
- `engine.go`:`NewEngine()` 指向 `SessionEngine`
|
||||||
|
- `transport/tpkt.go`:新增 `WriteRaw`
|
||||||
|
|
||||||
|
### 编译与部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && CGO_ENABLED=1 go build .
|
||||||
|
./run.sh docker up # 已验证镜像构建通过
|
||||||
|
```
|
||||||
|
|
||||||
|
### 实测备注
|
||||||
|
|
||||||
|
- CredSSP/NLA 对 `192.168.14.172:3389` 稳定通过(~7ms)
|
||||||
|
- MCS 握手在频繁探测时偶发 `connection reset`(目标机限流/策略)
|
||||||
|
- 详细协议说明见 [docs/rdp.md](./rdp.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-01 文档同步 + PageShell 设计系统
|
## 2026-07-01 文档同步 + PageShell 设计系统
|
||||||
|
|
||||||
### 完成项
|
### 完成项
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ const { list, total } = await getServers({ page: 1 });
|
|||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go build -o aiops-server .
|
go build -o aiops .
|
||||||
```
|
```
|
||||||
|
|
||||||
### 前端构建测试
|
### 前端构建测试
|
||||||
|
|||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
# AIOps Web RDP 远程桌面
|
||||||
|
|
||||||
|
> 作者:Logan.Li
|
||||||
|
> 更新:2026-07-03
|
||||||
|
> 归档副本:`/opt/rdp/docs/rdp.md` · 变更记录:`/opt/rdp/CHANGELOG.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
AIOps 提供浏览器内 Windows 远程桌面(Canvas 渲染 + WebSocket)。当前已移除第三方 `grdp` 兜底实现,后端固定使用 AIOps 自研 RDP 协议栈。// Logan.Li
|
||||||
|
|
||||||
|
| 环境变量 | 行为 |
|
||||||
|
|----------|------|
|
||||||
|
| `RDP_ENGINE` | 已不再参与引擎选择,保留环境变量不会改变行为 |
|
||||||
|
| `RDP_DEBUG=1` | 打开 `[RDP:TRACE]` 协议级日志 |
|
||||||
|
|
||||||
|
| 层级 | 状态 | 路径 |
|
||||||
|
|------|------|------|
|
||||||
|
| X.224 / TPKT / TLS | ✅ 自研灰度 | `backend/pkg/rdp/transport/` |
|
||||||
|
| CredSSP / NTLMv2 (NLA) | ✅ 自研灰度 | `backend/pkg/rdp/credssp/` |
|
||||||
|
| MCS / GCC / 许可证 / 激活 | ✅ 自研灰度 | `backend/pkg/rdp/mcs_probe.go`、`session_connect.go` |
|
||||||
|
| FastPath 位图 / MPPC / RLE | ✅ 自研灰度 | `bitmap_decode.go`、`fastpath_recv.go`、`mppc.go`、`rle/` |
|
||||||
|
| 键鼠 / 剪贴板 / auto_exec | ✅ 自研灰度 | `input_fastpath.go` |
|
||||||
|
| 文件传输 (SMB + 暂存回退) | ✅ 自研 | `pkg/winsmb/`、`pkg/rdpfilestage/` |
|
||||||
|
| 前端 Canvas + 工具面板 | ✅ 自研 | `RdpDialog.tsx`、`RdpToolsPanel.tsx` |
|
||||||
|
|
||||||
|
自研协议栈不依赖第三方 RDP 实现,也不直接依赖控制器、数据库或 WebSocket。已删除的第三方目录备份在 `/opt/rdp/third-party/grdp-aiops-backup-20260703`。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 变更记录
|
||||||
|
|
||||||
|
### 自研 RDP 引擎
|
||||||
|
|
||||||
|
| 变更项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 引擎入口 | `NewEngine()` 固定返回 `SessionEngine` |
|
||||||
|
| 兜底依赖 | 已移除 `github.com/nakagami/grdp` 和 `backend/third_party` |
|
||||||
|
| Dockerfile | 不再复制 third_party 模块 |
|
||||||
|
| 新增模块 | `session_engine.go`、`session_connect.go`、`packet_io.go`、`fastpath_recv.go`、`bitmap_decode.go`、`input_fastpath.go`、`mppc.go`、`rle/` |
|
||||||
|
| 自研会话激活 | `ConfirmActive` 后可选读 FontMap,发送 `SuppressOutput(ALLOW_DISPLAY_UPDATES)` 触发服务端推帧 |
|
||||||
|
| 自研位图输出 | FastPath / SlowPath Bitmap + SurfaceCmd → PNG base64 → WebSocket `type:bitmap` |
|
||||||
|
| 自研输入通道 | Fast-Path Input:扫描码键鼠、Unicode 剪贴板、`auto_exec`(Win+R 粘贴命令) |
|
||||||
|
| 协议日志 | `RDP_DEBUG=1` 输出 `[RDP:TRACE]`,定位连接、bitmap、input 阶段 |
|
||||||
|
|
||||||
|
### 仍保留的探测 API(诊断用)
|
||||||
|
|
||||||
|
| 函数 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `ProbeHandshake` | X.224 安全协议协商 |
|
||||||
|
| `ProbeCredSSP` | NLA 凭据验证 |
|
||||||
|
| `ProbeMCSConnect` | MCS/GCC 握手探测(读完即关连接) |
|
||||||
|
| `ProbeSessionFrames` | 建立会话后统计位图矩形数量 |
|
||||||
|
|
||||||
|
### 已知限制 / 待完善
|
||||||
|
|
||||||
|
- `mcs_probe.go` 超 700 行红线,后续需按 MCS/GCC/PDU 拆文件
|
||||||
|
- 未实现 cliprdr 虚拟通道双向同步、RDPGFX/H.264、动态虚拟通道
|
||||||
|
- 部分 Windows 主机在频繁探测后可能 `connection reset`(目标机策略/限流,非 NLA 失败)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构与调用链
|
||||||
|
|
||||||
|
```
|
||||||
|
RdpDialog.tsx
|
||||||
|
│ WebSocket /ws/rdp?id=&width=&height=&token=
|
||||||
|
▼
|
||||||
|
controller/rdp.go
|
||||||
|
│ loadRDPCredential() → engine.Run()
|
||||||
|
▼
|
||||||
|
pkg/rdp/engine.go → SessionEngine
|
||||||
|
├── session_engine.go readLoop + handleEvent
|
||||||
|
├── session_connect.go OpenSession (CredSSP + MCS + SuppressOutput)
|
||||||
|
├── packet_io.go TPKT / FastPath 读写
|
||||||
|
├── fastpath_recv.go 服务端更新解析
|
||||||
|
├── bitmap_decode.go RLE/翻转 → PNG
|
||||||
|
├── input_fastpath.go 键鼠 / 剪贴板 / auto_exec
|
||||||
|
├── mppc.go MPPC 解压
|
||||||
|
└── rle/ 位图 RLE 解压算法
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后端文件清单
|
||||||
|
|
||||||
|
### 协议栈 `backend/pkg/rdp/`
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `engine.go` | `Engine` 接口、`RDP_ENGINE` 引擎选择 |
|
||||||
|
| `client.go` | `ProbeHandshake` / `ProbeTLSHandshake` / `ProbeCredSSP` |
|
||||||
|
| `session_engine.go` | 图形会话主循环、帧推送、输入分发 |
|
||||||
|
| `session_connect.go` | `OpenSession`、SuppressOutput 激活 |
|
||||||
|
| `mcs_probe.go` | MCS Connect、GCC、ClientInfo、ConfirmActive 构建与探测 |
|
||||||
|
| `packet_io.go` | `ReadWirePacket`、`WriteFastPathInput` |
|
||||||
|
| `fastpath_recv.go` | FastPath 更新、MPPC、SurfaceCmd 解析 |
|
||||||
|
| `bitmap_decode.go` | 位图解码与 PNG 编码 |
|
||||||
|
| `input_fastpath.go` | Fast-Path Input 事件编码 |
|
||||||
|
| `mppc.go` | MPPC-64K 解压器 |
|
||||||
|
| `keymap.go` | 浏览器 `KeyboardEvent.code` → RDP 扫描码 |
|
||||||
|
| `transport/` | TPKT、X.224、TLS |
|
||||||
|
| `credssp/` | CredSSP + NTLMv2 + ASN.1 |
|
||||||
|
| `rle/` | RLE 位图解压(`DecompressInto`) |
|
||||||
|
|
||||||
|
### 控制器 / 模型
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `controller/rdp.go` | WebSocket 会话、审计录制 |
|
||||||
|
| `controller/rdp_session.go` | 会话列表 / 回放 / 终止 API |
|
||||||
|
| `controller/rdp_file.go` | 文件 list/upload/download/stage |
|
||||||
|
| `controller/rdp_cred.go` | 凭据加载辅助 |
|
||||||
|
| `model/rdp_session.go` | `RDPSession` 表 |
|
||||||
|
|
||||||
|
### 文件传输
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pkg/winsmb/` | SMB 管理共享直传 `C:\Users\Public\AIOpsShare\Download` |
|
||||||
|
| `pkg/rdpfilestage/` | 服务端暂存 + 一次性 token |
|
||||||
|
| `frontend/.../RdpToolsPanel.tsx` | 工具面板(通用命令 / 文件 Tab) |
|
||||||
|
| `frontend/.../rdpConstants.ts` | 虚拟 Download 盘路径常量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前端
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `RdpDialog.tsx` | Canvas 渲染、WebSocket、键鼠捕获、工具栏 |
|
||||||
|
| `RdpToolsPanel.tsx` | 剪贴板同步、文件上传下载、auto_exec 回退 |
|
||||||
|
| `api/server/rdp-files.ts` | 文件传输 REST |
|
||||||
|
| `api/audit/rdp.ts` | 会话审计 API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket 帧协议
|
||||||
|
|
||||||
|
### 服务端 → 浏览器
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "session", "message": "123", "width": 1280, "height": 800 }
|
||||||
|
{ "type": "status", "message": "正在连接 RDP ..." }
|
||||||
|
{ "type": "error", "message": "..." }
|
||||||
|
{ "type": "bitmap", "x": 0, "y": 0, "width": 64, "height": 64, "image": "data:image/png;base64,..." }
|
||||||
|
{ "type": "clipboard","text": "远端剪贴板内容" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 浏览器 → 服务端
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "mouse_move", "x": 100, "y": 200 }
|
||||||
|
{ "type": "mouse_down", "x": 100, "y": 200, "button": 0 }
|
||||||
|
{ "type": "mouse_up", "x": 100, "y": 200, "button": 0 }
|
||||||
|
{ "type": "wheel", "x": 100, "y": 200, "delta_y": -120 }
|
||||||
|
{ "type": "key", "code": "KeyA", "key": "a", "down": true }
|
||||||
|
{ "type": "clipboard", "text": "本地剪贴板" }
|
||||||
|
{ "type": "auto_exec", "text": "C:\\Users\\Public\\AIOpsShare\\Download\\setup.exe" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CredSSP 多策略重试
|
||||||
|
|
||||||
|
每条策略使用**新建 TCP 连接**(`client.go`):
|
||||||
|
|
||||||
|
1. `v6 + binding`(默认)
|
||||||
|
2. `v6 + binding + UseCBT`
|
||||||
|
3. `v6 + binding + ForceMIC`
|
||||||
|
4. `v2 + raw`(兼容旧服务端)
|
||||||
|
|
||||||
|
关键修复:`credssp.subjectPublicKey()` 使用证书 `SubjectPublicKey` 原始 ASN.1,而非 PKIX SPKI。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 外部项目借鉴边界
|
||||||
|
|
||||||
|
| 项目 | 可借鉴点 | 不直接采用原因 |
|
||||||
|
|------|----------|----------------|
|
||||||
|
| FreeRDP | 协议完整性、通道、编解码、客户端能力矩阵 | C 生态和体量过大,不适合直接嵌入 |
|
||||||
|
| Apache Guacamole | Web 网关、协议代理、录制与前端桥接分层 | 依赖 guacd/C,不符合单 Go 后端目标 |
|
||||||
|
| IronRDP | 现代模块化、状态机测试、安全边界 | Rust 生态,适合作为测试和分层参考 |
|
||||||
|
| Teleport | 身份、RBAC、审计、Desktop Gateway 架构 | 产品目标偏堡垒机,不是轻量 RDP client |
|
||||||
|
| tomatome/grdp / nakagami/grdp | Go 协议状态机、能力集、bitmap/input 参考 | 第三方实现已移除,仅保留 `/opt` 备份用于协议对照 |
|
||||||
|
|
||||||
|
## 第三方代码归档与文档
|
||||||
|
|
||||||
|
归档与变更记录目录:
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/rdp/
|
||||||
|
├── CHANGELOG.md # 变更记录
|
||||||
|
├── README.md # 索引
|
||||||
|
├── docs/rdp.md # 完整说明(与本文档同步)
|
||||||
|
└── third-party/
|
||||||
|
├── nakagami-grdp-v0.8.7/
|
||||||
|
├── rdp-web-refs/
|
||||||
|
└── grdpprobe/
|
||||||
|
```
|
||||||
|
|
||||||
|
> 第三方源码只能作为协议行为和工程分层参考;涉及 GPL 的项目不得直接搬运实现。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编译
|
||||||
|
cd backend && CGO_ENABLED=1 go build -o aiops .
|
||||||
|
|
||||||
|
# NLA 探测
|
||||||
|
go run /tmp/rdp_cred_probe.go # ProbeCredSSP
|
||||||
|
|
||||||
|
# 会话位图统计(需可达的 Windows:3389)
|
||||||
|
go run /tmp/rdp_session_probe_main.go
|
||||||
|
|
||||||
|
# Docker 端到端
|
||||||
|
./run.sh docker up
|
||||||
|
# 浏览器:服务器管理 → Windows 主机 → RDP 连接
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关路由
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `GET /ws/rdp` | WebSocket 图形会话 |
|
||||||
|
| `GET /api/v1/rdp-sessions` | 会话审计列表 |
|
||||||
|
| `GET /api/v1/servers/:id/rdp-files` | 远端文件列表 |
|
||||||
|
| `POST /api/v1/servers/:id/rdp-files/upload` | 上传 |
|
||||||
|
| `GET /api/v1/servers/:id/rdp-files/download` | 下载 |
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
# AIOps 终端与远程桌面设计说明
|
||||||
|
|
||||||
|
> 更新:2026-07-03
|
||||||
|
> 范围:SSH WebShell、Windows RDP、Kubernetes Pod exec、kubectl 集群终端、流水线 Workspace 终端、审计实时查看与历史回放。
|
||||||
|
> 本文用于记录当前实现、设计取舍与后续重构方向。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体目标
|
||||||
|
|
||||||
|
AIOps 的终端体系不是单一功能,而是一组共享基础能力:
|
||||||
|
|
||||||
|
1. **实时交互**:浏览器通过 WebSocket 与后端建立交互式会话。
|
||||||
|
2. **尺寸自适应**:前端终端容器变化后执行 `fit()`,并同步 `cols/rows` 到远端 PTY。
|
||||||
|
3. **审计留痕**:会话起止、用户、资产、输入动作、命令、输出大小、录制数据可追溯。
|
||||||
|
4. **历史回放**:SSH 终端使用文本流回放,RDP 使用 Canvas 图形帧回放。
|
||||||
|
5. **统一体验**:SSH、Pod、kubectl、Workspace 终端共享 xterm.js 适配策略;RDP 使用 Canvas 独立渲染策略。
|
||||||
|
|
||||||
|
核心原则:
|
||||||
|
|
||||||
|
- 终端输入只走实时链路,审计只记录有价值的离散事件。
|
||||||
|
- `mousemove` 等高频低价值事件不持久化。
|
||||||
|
- RDP 历史回放采用节流图形帧,不保存每个实时帧,避免存储爆炸。
|
||||||
|
- 前端终端窗口不手写分散 resize 逻辑,统一走 `frontend/src/lib/xterm-utils.ts`。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 统一链路
|
||||||
|
|
||||||
|
### 2.1 SSH / Pod / kubectl / Workspace
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser xterm.js
|
||||||
|
├─ onData → WebSocket JSON {status:0,data}
|
||||||
|
├─ ResizeObserver + FitAddon
|
||||||
|
└─ resize → WebSocket JSON {status:1,cols,rows}
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Backend WebSocket Handler
|
||||||
|
├─ SSH: ssh.Session + PTY
|
||||||
|
├─ Pod exec: Kubernetes channel.k8s.io WebSocket
|
||||||
|
├─ kubectl: local kubectl process + PTY
|
||||||
|
└─ Workspace: pipeline workspace shell + PTY
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Audit / Recording
|
||||||
|
├─ terminal session metadata
|
||||||
|
├─ asciicast text stream
|
||||||
|
└─ command records
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Windows RDP
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Canvas
|
||||||
|
├─ mouse/key/clipboard input → WebSocket JSON
|
||||||
|
└─ bitmap frames ← WebSocket binary RDPR
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
controller/rdp.go
|
||||||
|
├─ load RDP credential
|
||||||
|
├─ create RDPSession
|
||||||
|
├─ start Engine.Run()
|
||||||
|
├─ broadcast live audit frames
|
||||||
|
└─ throttle snapshot frames for replay
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
pkg/rdp Engine
|
||||||
|
└─ AIOps RDP protocol engine
|
||||||
|
```
|
||||||
|
|
||||||
|
RDP 与 xterm 终端不同:它没有 PTY 的 `cols/rows` 概念,主要优化点是输入事件合并、图形帧编码、Canvas 绘制和录屏抽样。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 前端终端窗口适配
|
||||||
|
|
||||||
|
公共工具:`frontend/src/lib/xterm-utils.ts`
|
||||||
|
|
||||||
|
| 方法 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `fitTerminal()` | 调用 FitAddon,并在 WebSocket 打开时发送 `{status:1,cols,rows}` |
|
||||||
|
| `mountXterm()` | 等容器渲染完成后挂载 xterm,延迟多次 fit,减少右侧黑边 |
|
||||||
|
| `attachTerminalResize()` | 通过 ResizeObserver 监听容器尺寸,节流 fit 和 resize 同步 |
|
||||||
|
|
||||||
|
已接入或应接入的组件:
|
||||||
|
|
||||||
|
| 组件 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `TerminalDialog.tsx` | SSH 单会话终端 | 资产列表打开 SSH |
|
||||||
|
| `WebShell.tsx` | SSH 多标签终端 | WebShell 页面 |
|
||||||
|
| `PodExecTerminal.tsx` | Pod exec | K8s Pod 容器终端 |
|
||||||
|
| `KubectlTerminal.tsx` | kubectl | 集群级 kubectl shell |
|
||||||
|
| `WorkspaceTermDialog.tsx` | Workspace shell | 流水线构建目录终端 |
|
||||||
|
| `LiveMonitorDialog.tsx` | SSH 实时审计 | 只读 xterm |
|
||||||
|
| `ReplayDialog.tsx` | SSH 历史回放 | asciicast 回放 |
|
||||||
|
|
||||||
|
终端容器统一使用:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div ref={containerRef} className="xterm-terminal-host" />
|
||||||
|
```
|
||||||
|
|
||||||
|
全局样式位置:`frontend/src/index.css`
|
||||||
|
|
||||||
|
```css
|
||||||
|
.xterm-terminal-host {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0e14;
|
||||||
|
}
|
||||||
|
.xterm-terminal-host .xterm {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意事项:
|
||||||
|
|
||||||
|
- Dialog 动画会影响 xterm 首次测量,因此终端弹框禁用过渡。
|
||||||
|
- `fit()` 后必须同步后端 PTY 尺寸,否则只是前端铺满,远端程序仍按旧尺寸输出。
|
||||||
|
- `term.open()` 只能对同一个 xterm 实例执行一次,复用场景需要检查 `term.element`。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SSH 终端实现
|
||||||
|
|
||||||
|
主要文件:
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/controller/webshell.go` | SSH WebSocket、PTY、录制、命令审计 |
|
||||||
|
| `backend/model/terminal_session.go` | SSH 会话模型 |
|
||||||
|
| `backend/model/session_command.go` | 命令审计模型 |
|
||||||
|
| `frontend/src/components/server/TerminalDialog.tsx` | 单资产 SSH 终端 |
|
||||||
|
| `frontend/src/components/server/WebShell.tsx` | 多标签 SSH 终端 |
|
||||||
|
|
||||||
|
协议:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": 0, "data": "ls -la\n" }
|
||||||
|
{ "status": 1, "cols": 120, "rows": 34 }
|
||||||
|
```
|
||||||
|
|
||||||
|
审计策略:
|
||||||
|
|
||||||
|
- 会话记录:开始时间、结束时间、用户、资产、状态、录制大小。
|
||||||
|
- 录制数据:asciicast 文本流。
|
||||||
|
- 命令记录:按回车提交后提取命令,不记录每个按键。
|
||||||
|
- 输出统计:下一条命令前累计 stdout 字节数写入上一条命令。
|
||||||
|
|
||||||
|
优化方向:
|
||||||
|
|
||||||
|
- 命令列表按会话聚合展示,减少碎片行。
|
||||||
|
- 对连续输入、粘贴、多行脚本增加摘要视图。
|
||||||
|
- 高危命令识别只用于展示和告警,不阻塞正常录制。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Kubernetes Pod Exec
|
||||||
|
|
||||||
|
主要文件:
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/controller/k8s_pod.go` | Pod exec WebSocket 代理 |
|
||||||
|
| `frontend/src/components/kubernetes/PodExecTerminal.tsx` | Pod 终端 UI |
|
||||||
|
|
||||||
|
链路:
|
||||||
|
|
||||||
|
```
|
||||||
|
xterm.js
|
||||||
|
└─ WebSocket JSON
|
||||||
|
└─ backend
|
||||||
|
└─ Kubernetes API WebSocket channel.k8s.io
|
||||||
|
```
|
||||||
|
|
||||||
|
K8s channel 映射:
|
||||||
|
|
||||||
|
| Channel | 方向 | 说明 |
|
||||||
|
|---------|------|------|
|
||||||
|
| 0 | client → pod | stdin |
|
||||||
|
| 1 | pod → client | stdout |
|
||||||
|
| 2 | pod → client | stderr |
|
||||||
|
| 3 | pod → client | error/status |
|
||||||
|
| 4 | client → pod | resize |
|
||||||
|
|
||||||
|
前端 resize JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": 1, "cols": 120, "rows": 34 }
|
||||||
|
```
|
||||||
|
|
||||||
|
后端转换为 K8s resize frame:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "Width": 120, "Height": 34 }
|
||||||
|
```
|
||||||
|
|
||||||
|
注意事项:
|
||||||
|
|
||||||
|
- Pod exec 不应进入 SSH 命令审计表,除非未来设计 K8s 独立审计模型。
|
||||||
|
- 容器切换、shell 切换会重连 WebSocket。
|
||||||
|
- ESC 必须传给终端,Dialog 不能用 ESC 自动关闭。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. kubectl 集群终端
|
||||||
|
|
||||||
|
主要文件:
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/controller/kubectl_shell.go` | kubectl shell WebSocket |
|
||||||
|
| `frontend/src/components/kubernetes/KubectlTerminal.tsx` | kubectl 终端 UI |
|
||||||
|
|
||||||
|
设计目标:
|
||||||
|
|
||||||
|
- 给运维提供集群级 kubectl shell。
|
||||||
|
- 使用与 SSH 相同的 xterm 自适应策略。
|
||||||
|
- 后端负责 kubeconfig/集群上下文隔离。
|
||||||
|
|
||||||
|
风险点:
|
||||||
|
|
||||||
|
- kubectl shell 权限大,必须依赖 RBAC、审计和操作权限控制。
|
||||||
|
- 后续应对危险命令做二次确认或审计标记。
|
||||||
|
- 多集群切换时不能复用旧进程上下文。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Workspace 终端
|
||||||
|
|
||||||
|
主要文件:
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/controller/workspace_files.go` | 流水线工作区文件/终端入口 |
|
||||||
|
| `frontend/src/components/deploy/WorkspaceTermDialog.tsx` | 工作区终端 UI |
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 进入流水线构建目录查看文件。
|
||||||
|
- 辅助排查构建产物、日志、临时文件。
|
||||||
|
|
||||||
|
设计约束:
|
||||||
|
|
||||||
|
- 默认目录必须限制在 pipeline workspace 内。
|
||||||
|
- 后端不能允许用户通过路径逃逸进入任意系统目录。
|
||||||
|
- 终端尺寸适配与 SSH 共用 `xterm-utils`。// Logan.Li
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. RDP 实现
|
||||||
|
|
||||||
|
主要文件:
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/controller/rdp.go` | RDP WebSocket、会话、审计、实时帧推送 |
|
||||||
|
| `backend/controller/rdp_live.go` | RDP 实时审计广播 |
|
||||||
|
| `backend/controller/rdp_session.go` | RDP 会话列表、录制、终止 API |
|
||||||
|
| `backend/pkg/rdp/engine.go` | 引擎选择 |
|
||||||
|
| `backend/pkg/rdp/session_engine.go` | 自研 RDP 引擎主循环 |
|
||||||
|
| `frontend/src/components/server/RdpDialog.tsx` | RDP 实时操作窗口 |
|
||||||
|
| `frontend/src/components/system/RdpAuditViewer.tsx` | RDP 实时审计与历史回放 |
|
||||||
|
|
||||||
|
### 8.1 自研 RDP 引擎策略
|
||||||
|
|
||||||
|
当前后端固定使用自研 `SessionEngine`,不再通过 `RDP_ENGINE` 切换第三方兜底。已删除的 grdp 目录备份到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/rdp/third-party/grdp-aiops-backup-20260703
|
||||||
|
```
|
||||||
|
|
||||||
|
仓内 `backend/third_party` 已清理,Dockerfile 不再复制本地 replace 模块。// Logan.Li
|
||||||
|
|
||||||
|
### 8.2 WebSocket 帧协议
|
||||||
|
|
||||||
|
服务端到浏览器:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "session", "message": "123", "width": 1024, "height": 800 }
|
||||||
|
{ "type": "status", "message": "正在连接 RDP ..." }
|
||||||
|
{ "type": "error", "message": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
图像帧优先走二进制:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RDPR + x:uint32 + y:uint32 + width:uint32 + height:uint32 + rgba:[]
|
||||||
|
```
|
||||||
|
|
||||||
|
历史回放帧走节流 PNG:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "bitmap", "x": 0, "y": 0, "width": 300, "height": 200, "image": "data:image/png;base64,..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器到服务端:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "mouse_move", "x": 100, "y": 200 }
|
||||||
|
{ "type": "mouse_down", "x": 100, "y": 200, "button": 0 }
|
||||||
|
{ "type": "mouse_up", "x": 100, "y": 200, "button": 0 }
|
||||||
|
{ "type": "wheel", "x": 100, "y": 200, "delta_y": -120 }
|
||||||
|
{ "type": "key", "code": "KeyA", "key": "a", "down": true }
|
||||||
|
{ "type": "clipboard", "text": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 输入优化
|
||||||
|
|
||||||
|
- 前端 `mousemove` 16ms 合并,只保留最新坐标。
|
||||||
|
- `mouse_down`、`mouse_up` 前先 flush 最新坐标,保证拖拽起止准确。
|
||||||
|
- 后端输入队列满时丢弃移动事件,关键事件优先。
|
||||||
|
- RDP 引擎处理输入时不打印每个 `mousemove`,避免日志拖慢实时操作。
|
||||||
|
|
||||||
|
### 8.4 图形优化
|
||||||
|
|
||||||
|
- 实时图形帧使用 RGBA 二进制直传,历史回放使用节流 PNG 快照。
|
||||||
|
- 前端 Canvas 使用 `putImageData()` 绘制二进制帧。
|
||||||
|
- 右下角帧统计节流刷新,避免每帧 React 重渲染。
|
||||||
|
- RDP 录屏只保存节流快照,不保存每个实时帧。
|
||||||
|
|
||||||
|
### 8.5 审计策略
|
||||||
|
|
||||||
|
持久化:
|
||||||
|
|
||||||
|
- 会话元数据:用户、资产、开始/结束时间、状态、录制大小。
|
||||||
|
- 离散操作:点击、滚轮、剪贴板、文件上传下载、输入摘要。
|
||||||
|
- 历史回放:节流图形快照。
|
||||||
|
|
||||||
|
不持久化:
|
||||||
|
|
||||||
|
- 高频 `mousemove`。
|
||||||
|
- 每个实时 RGBA 帧。
|
||||||
|
- 无业务含义的连续坐标变化。
|
||||||
|
|
||||||
|
拖拽建议记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[RDP拖拽] button=left start=(x1,y1) end=(x2,y2) duration=850ms
|
||||||
|
```
|
||||||
|
|
||||||
|
普通点击建议记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[RDP点击] button=left x=100 y=200
|
||||||
|
```
|
||||||
|
|
||||||
|
键盘输入建议合并:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[RDP输入] abc*** # 敏感字段后续可脱敏
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 审计页设计
|
||||||
|
|
||||||
|
当前菜单:`/audit/terminal`
|
||||||
|
|
||||||
|
建议子页签:
|
||||||
|
|
||||||
|
| 页签 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 在线会话 | SSH 在线会话 + RDP 在线会话 |
|
||||||
|
| 历史会话 | SSH 历史 asciicast 回放 + RDP Canvas 回放 |
|
||||||
|
| 会话命令 | SSH 命令 + RDP 操作摘要,按会话聚合展示 |
|
||||||
|
|
||||||
|
### 9.1 在线会话
|
||||||
|
|
||||||
|
SSH:
|
||||||
|
|
||||||
|
- 使用 `LiveMonitorDialog`
|
||||||
|
- 后端 `/ws/audit/:sessionId`
|
||||||
|
- 只读 xterm
|
||||||
|
|
||||||
|
RDP:
|
||||||
|
|
||||||
|
- 使用 `RdpAuditViewer mode="live"`
|
||||||
|
- 后端 `/ws/rdp-audit/:sessionId`
|
||||||
|
- 只读 Canvas
|
||||||
|
|
||||||
|
### 9.2 历史会话
|
||||||
|
|
||||||
|
SSH:
|
||||||
|
|
||||||
|
- 使用 `ReplayDialog`
|
||||||
|
- 数据源:`terminalSessionApi.getRecord()`
|
||||||
|
- 格式:asciicast 文本
|
||||||
|
|
||||||
|
RDP:
|
||||||
|
|
||||||
|
- 使用 `RdpAuditViewer mode="replay"`
|
||||||
|
- 数据源:`rdpSessionApi.getRecord()`
|
||||||
|
- 格式:节流 PNG bitmap JSON
|
||||||
|
|
||||||
|
### 9.3 会话命令 / 操作摘要
|
||||||
|
|
||||||
|
问题:
|
||||||
|
|
||||||
|
- SSH 命令记录适合逐条展示。
|
||||||
|
- RDP 如果按键、点击、剪贴板都逐条展示,会出现大量低价值行。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
1. 后端写入时尽量合并:
|
||||||
|
- 连续键盘输入合并为 `[RDP输入] ...`
|
||||||
|
- 拖拽只记录开始/结束。
|
||||||
|
- 点击和滚轮按短时间窗口合并。
|
||||||
|
2. 前端展示时按会话聚合:
|
||||||
|
- 默认展示会话摘要。
|
||||||
|
- 展开后看详细事件。
|
||||||
|
- 高危动作、文件传输、剪贴板单独标记。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 当前架构质量评估
|
||||||
|
|
||||||
|
### 10.1 做得好的地方
|
||||||
|
|
||||||
|
- `backend/pkg/rdp/domain` 定义了引擎接口,控制器不直接依赖协议细节。
|
||||||
|
- `transport/`、`credssp/`、`rle/`、`display/` 已经有基础分层。
|
||||||
|
- RDP 依赖已收敛到自研协议栈,构建不再需要 `backend/third_party`。
|
||||||
|
- xterm 终端已抽出公共适配工具,后续维护成本低。
|
||||||
|
- RDP 实时帧和历史回放帧开始分离,避免审计存储被实时帧撑爆。
|
||||||
|
|
||||||
|
### 10.2 需要优化的地方
|
||||||
|
|
||||||
|
| 文件 | 问题 | 建议 |
|
||||||
|
|------|------|------|
|
||||||
|
| `backend/pkg/rdp/mcs_probe.go` | 1664 行,严重超过红线 | 拆成 `mcs_connect.go`、`gcc_caps.go`、`confirm_active.go`、`client_info.go`、`probe.go` |
|
||||||
|
| `backend/controller/rdp.go` | 约 460 行,承担会话、审计、帧编码、快照 | 拆出 `rdp_sink.go`、`rdp_audit.go`、`rdp_input_audit.go` |
|
||||||
|
| `RdpDialog.tsx` | 实时渲染、输入、工具面板入口集中 | 后续拆出 `useRdpSocket()`、`useRdpCanvas()`、`useRdpInput()` |
|
||||||
|
| `PodExecTerminal.tsx` | 连接、UI、resize 混合 | 可抽 `useXtermWebSocket()` |
|
||||||
|
| `KubectlTerminal.tsx` | 与 Pod exec 重复 | 与 Pod/Workspace 共用 hook |
|
||||||
|
|
||||||
|
### 10.3 建议目标结构
|
||||||
|
|
||||||
|
后端 RDP:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/pkg/rdp/
|
||||||
|
├── domain/
|
||||||
|
├── engine.go
|
||||||
|
├── session_engine.go
|
||||||
|
├── input/
|
||||||
|
│ ├── fastpath.go
|
||||||
|
│ └── keymap.go
|
||||||
|
├── graphics/
|
||||||
|
│ ├── framebuffer.go
|
||||||
|
│ ├── codec.go
|
||||||
|
│ └── rle/
|
||||||
|
├── mcs/
|
||||||
|
│ ├── connect.go
|
||||||
|
│ ├── gcc.go
|
||||||
|
│ ├── caps.go
|
||||||
|
│ └── active.go
|
||||||
|
├── credssp/
|
||||||
|
└── transport/
|
||||||
|
```
|
||||||
|
|
||||||
|
控制器:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/controller/
|
||||||
|
├── rdp.go # 路由入口和会话编排
|
||||||
|
├── rdp_sink.go # WebSocket frame sink
|
||||||
|
├── rdp_live.go # 实时审计广播
|
||||||
|
├── rdp_input_audit.go # 点击/拖拽/输入摘要
|
||||||
|
├── rdp_session.go # 会话 REST API
|
||||||
|
├── rdp_file.go
|
||||||
|
└── rdp_cred.go
|
||||||
|
```
|
||||||
|
|
||||||
|
前端:
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/src/components/terminal/
|
||||||
|
├── useXtermSocket.ts
|
||||||
|
├── XtermDialogShell.tsx
|
||||||
|
└── terminalTheme.ts
|
||||||
|
|
||||||
|
frontend/src/components/rdp/
|
||||||
|
├── useRdpSocket.ts
|
||||||
|
├── useRdpCanvas.ts
|
||||||
|
├── useRdpInput.ts
|
||||||
|
├── RdpDialog.tsx
|
||||||
|
└── RdpAuditViewer.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 验证清单
|
||||||
|
|
||||||
|
终端类修改后建议验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx tsc --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
后端修改后建议验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go build -o aiops .
|
||||||
|
```
|
||||||
|
|
||||||
|
端到端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh docker up
|
||||||
|
python3 tests/integration_test.py --host localhost --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
手动检查:
|
||||||
|
|
||||||
|
- SSH 终端打开、缩放、全屏、输入、回放。
|
||||||
|
- Pod exec 打开、切 shell、缩放、全屏。
|
||||||
|
- kubectl 终端打开、缩放、全屏。
|
||||||
|
- Workspace 终端打开、缩放、全屏。
|
||||||
|
- RDP 打开、拖动窗口、文件夹拖拽、实时审计、历史回放。
|
||||||
|
- 会话命令页筛选、导出、从命令跳转回放。// Logan.Li
|
||||||
@@ -35,6 +35,14 @@ export default defineConfig([
|
|||||||
'error',
|
'error',
|
||||||
{ fixToUnknown: false, ignoreRestArgs: true },
|
{ fixToUnknown: false, ignoreRestArgs: true },
|
||||||
],
|
],
|
||||||
|
// React Compiler 迁移型规则先作为告警推进,避免历史页面一次性阻断本地门禁。// Logan.Li
|
||||||
|
'react-hooks/static-components': 'warn',
|
||||||
|
'react-hooks/set-state-in-effect': 'warn',
|
||||||
|
'react-hooks/purity': 'warn',
|
||||||
|
'react-hooks/immutability': 'warn',
|
||||||
|
'react-hooks/preserve-manual-memoization': 'warn',
|
||||||
|
// 工具函数和组件同文件导出在历史代码中较多,先降级为告警后续逐步拆分。// Logan.Li
|
||||||
|
'react-refresh/only-export-components': 'warn',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "my-app",
|
"name": "aiops-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-app",
|
"name": "aiops-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@codemirror/lang-yaml": "^6.1.3",
|
"@codemirror/lang-yaml": "^6.1.3",
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "my-app",
|
"name": "aiops-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"check": "npm run typecheck",
|
||||||
|
"check:strict": "npm run lint && npm run typecheck",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user