diff --git a/.gitignore b/.gitignore index b5e1613..2a44cce 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ frontend/node_modules/ *.dmg # Build output +.cache/ frontend/dist/ frontend/dist-ssr/ backend/bin/ @@ -80,6 +81,7 @@ docker-data/ *.pid agent/aiops-agent agent/bin/ +backend/agent/ # embed 模式临时目录(前端构建产物,编译后清理) # 保留 .gitkeep 占位文件确保 go:embed all:static 在全新 clone 后可编译 diff --git a/AGENTS.md b/AGENTS.md index 3012c67..65c681f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,8 +220,8 @@ aiops/ ```bash cd backend -CGO_ENABLED=1 go build -o aiops-server . -./aiops-server +CGO_ENABLED=1 go build -o aiops . +./aiops # 或 go run . ``` @@ -320,7 +320,7 @@ npm run lint # ESLint 检查 ```bash cd backend 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。 4. **GORM 指针字段必须解引用**后再使用(`*field`)。 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()` 代替。 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`(零错误) 3. `./run.sh docker up` 或 `./run.sh dev` 启动完整环境 4. `python3 tests/integration_test.py --host localhost --port 8080` 通过 diff --git a/CLAUDE.md b/CLAUDE.md index a413da3..2e5a832 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,10 +117,10 @@ 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 ./... diff --git a/Dockerfile b/Dockerfile index 8047db4..47f1d19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,7 @@ COPY frontend/ ./ RUN npm run build # ==================== Stage 2: Backend Builder ==================== -FROM golang:1.24-alpine AS backend-builder +FROM golang:1.25-alpine AS backend-builder ARG VERSION ARG BUILD_TIME @@ -52,7 +52,7 @@ ENV GONOSUMCHECK=* ENV GOMODCACHE=/go/pkg/mod ENV GOCACHE=/root/.cache/go-build -# 复制后端 go.mod(利用 Docker 层缓存) +# 复制后端 go.mod,第三方 RDP 兜底依赖已移除,后端使用自研 RDP 引擎。 // Logan.Li COPY backend/go.mod backend/go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..20d40b6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. \ No newline at end of file diff --git a/Makefile b/Makefile index 5a017d8..1f2391c 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ build-ui: build-api: @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 @@ -69,7 +69,7 @@ lint: clean: @echo "清理构建产物..." - rm -f backend/aiops-server + rm -f backend/aiops backend/aiops-server rm -rf frontend/dist rm -f backend/logs/*.log frontend/logs/*.log @echo "清理完成" @@ -90,9 +90,9 @@ docker-logs: # ==================== 二进制部署(embed 模式) ==================== build-binary: build-ui @echo "构建单二进制(前端嵌入后端)..." - @mkdir -p backend/cmd/static - @cp -r frontend/dist/* backend/cmd/static/ - cd backend && CGO_ENABLED=0 go build -ldflags="-s -w" -o ../aiops ./cmd/ - @rm -rf backend/cmd/static + @rm -rf backend/static + @mkdir -p backend/static + @cp -r frontend/dist/* backend/static/ + cd backend && CGO_ENABLED=1 go build -ldflags="-s -w" -o ../aiops . @echo "✓ 二进制已生成: ./aiops" @echo " 部署: scp aiops deploy/binary/install.sh root@:/tmp/ && ssh root@ 'cd /tmp && bash install.sh install'" diff --git a/README.md b/README.md index fe02872..246e829 100644 --- a/README.md +++ b/README.md @@ -236,4 +236,4 @@ aiops/ ## License -MIT \ No newline at end of file +GNU General Public License v3.0 or later (GPL-3.0-or-later). See [LICENSE](./LICENSE). diff --git a/backend/README.md b/backend/README.md index 38c57ce..696c626 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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. diff --git a/backend/agent/bin/aiops-agent-darwin-arm64 b/backend/agent/bin/aiops-agent-darwin-arm64 deleted file mode 100755 index 4d2aa08..0000000 Binary files a/backend/agent/bin/aiops-agent-darwin-arm64 and /dev/null differ diff --git a/backend/agent/bin/aiops-agent-linux-amd64 b/backend/agent/bin/aiops-agent-linux-amd64 deleted file mode 100755 index 55c7a3f..0000000 Binary files a/backend/agent/bin/aiops-agent-linux-amd64 and /dev/null differ diff --git a/backend/agent/bin/aiops-agent-linux-arm64 b/backend/agent/bin/aiops-agent-linux-arm64 deleted file mode 100755 index e2ff8c1..0000000 Binary files a/backend/agent/bin/aiops-agent-linux-arm64 and /dev/null differ diff --git a/backend/agent/deploy/install.sh b/backend/agent/deploy/install.sh deleted file mode 100755 index 96d2562..0000000 --- a/backend/agent/deploy/install.sh +++ /dev/null @@ -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 | sudo bash -s -- --server --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 - - - - - Label - ${PLIST_LABEL} - ProgramArguments - - ${INSTALL_DIR}/aiops-agent - -server - ${AIOPS_SERVER} - -interval - 10 - -token - ${AGENT_TOKEN} - - RunAtLoad - - KeepAlive - - StandardOutPath - /var/log/aiops-agent.log - StandardErrorPath - /var/log/aiops-agent.err.log - - -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 diff --git a/backend/cloud/aliyun.go b/backend/cloud/aliyun.go index d367af5..c233c57 100644 --- a/backend/cloud/aliyun.go +++ b/backend/cloud/aliyun.go @@ -66,15 +66,56 @@ func (a *AliyunProvider) sign(params map[string]string) string { 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) { if endpoint == "" { 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{ "Format": "JSON", - "Version": a.version, + "Version": version, "AccessKeyId": a.accessKey, "SignatureMethod": "HMAC-SHA1", "Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"), diff --git a/backend/cloud/aliyun_billing.go b/backend/cloud/aliyun_billing.go new file mode 100644 index 0000000..371d1c9 --- /dev/null +++ b/backend/cloud/aliyun_billing.go @@ -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 +} \ No newline at end of file diff --git a/backend/cloud/billing.go b/backend/cloud/billing.go new file mode 100644 index 0000000..6da6050 --- /dev/null +++ b/backend/cloud/billing.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go index 434ab4d..56f6e56 100644 --- a/backend/controller/agent_cli.go +++ b/backend/controller/agent_cli.go @@ -1,5 +1,5 @@ // agent_cli.go - Agent CLI WebSocket 控制器 // Logan.Li -// 驱动 pkg/agentcli 引擎:关联主机(model.Server 凭据)→ 构建引擎 → ReAct 循环, +// 驱动 pkg/agentcli 引擎:主机模式(SSH)或 K8s 模式(集群查询/Pod 执行)→ ReAct 循环, // 通过 WebSocket 实时推送思考/工具调用/输出/结果,高危命令经前端确认,过程持久化到 DB。 package controller @@ -78,22 +78,37 @@ type agentConn struct { conn *websocket.Conn db *gorm.DB userID uint + mode string server model.Server + sshCfg remoteexec.SSHConfig + kubernetesCtx *kubernetesAgentContext session *model.AgentCLISession taskCtx context.Context cancel context.CancelFunc totalTokens int tokenMu sync.Mutex writeMu sync.Mutex - confirms map[string]chan bool - cfMu sync.Mutex - convHistory []llm.Message // 多轮追问对话历史(不含 system) // Logan.Li - running bool - runningMu sync.Mutex + confirms map[string]chan bool + cfMu sync.Mutex + convHistory []llm.Message + running bool + runningMu sync.Mutex } -// Run WebSocket 入口:GET /ws/agent-cli?id=&token= // Logan.Li +// Run WebSocket 入口 // Logan.Li +// 主机: GET /ws/agent-cli?mode=host&id=&token= +// Kubernetes: GET /ws/agent-cli?mode=kubernetes&cluster=&namespace=&pod=<可选>&container=<可选>&token= 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") if serverID == "" { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少主机 id"}) @@ -105,21 +120,17 @@ func (h *AgentCLIHandler) Run(c *gin.Context) { return } - // 先升级 WebSocket:SSH 凭据 / LLM 配置预检放到升级之后,失败时通过 WS error 事件 - // 把真实原因推给前端,避免握手 400 导致前端"一直转圈"看不到原因 // Logan.Li conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } defer conn.Close() - // SSH 凭据预检 sshCfg, err := buildSSHConfig(h.db, server) if err != nil { writeWSCloseError(conn, "SSH 连接失败: "+err.Error()) return } - // LLM 配置预检 var llmCfg model.LLMConfig if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled { writeWSCloseError(conn, "LLM 服务未配置或未启用,请先在「大模型设置」中配置") @@ -127,18 +138,57 @@ func (h *AgentCLIHandler) Run(c *gin.Context) { } ac := &agentConn{ - conn: conn, db: h.db, - userID: middleware.GetUserID(c), server: server, - taskCtx: context.Background(), - confirms: make(map[string]chan bool), + conn: conn, db: h.db, mode: agentcli.RunModeHost, + userID: middleware.GetUserID(c), server: server, sshCfg: sshCfg, + taskCtx: context.Background(), confirms: make(map[string]chan bool), } - // 推送主机上下文 ac.write(wsEvent{Type: "session", SessionID: 0, 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 { - _, data, err := conn.ReadMessage() + _, data, err := ac.conn.ReadMessage() if err != nil { ac.abort() return @@ -153,13 +203,13 @@ func (h *AgentCLIHandler) Run(c *gin.Context) { ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"}) continue } - ac.startTask(sshCfg, ctrl.Query, ctrl.SafetyMode) + ac.startTask(ctrl.Query, ctrl.SafetyMode) case "continue": if ctrl.Query == "" { ac.write(wsEvent{Type: "error", Message: "追问内容不能为空"}) continue } - ac.continueTask(sshCfg, ctrl.Query, ctrl.SafetyMode) + ac.continueTask(ctrl.Query, ctrl.SafetyMode) case "resume": if ctrl.SessionID == 0 { ac.write(wsEvent{Type: "error", Message: "缺少 session_id"}) @@ -189,7 +239,7 @@ func parseSafetyMode(mode string) safety.Mode { } // startTask 启动新任务(新会话,清空对话历史) // Logan.Li -func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) { +func (ac *agentConn) startTask(query, safetyMode string) { ac.abort() var llmCfg model.LLMConfig @@ -203,11 +253,20 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode st title = string(r[:60]) + "..." } sess := &model.AgentCLISession{ - UserID: ac.userID, ServerID: ac.server.ID, - ServerName: ac.server.Hostname, ServerHost: ac.server.IP, - Title: title, Status: "running", + UserID: ac.userID, Mode: ac.mode, Title: title, Status: "running", 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 { ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()}) return @@ -215,7 +274,7 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode st ac.session = sess ac.convHistory = nil ac.setTotalTokens(0) - ac.runTask(sshCfg, sess, query, safetyMode, llmCfg, false) + ac.runTask(sess, query, safetyMode, llmCfg, false) } // resumeSession 从历史会话恢复上下文,供继续追问 // Logan.Li @@ -225,8 +284,16 @@ func (ac *agentConn) resumeSession(sessionID uint) { return } var sess model.AgentCLISession - if err := ac.db.Where("user_id = ? AND server_id = ?", ac.userID, ac.server.ID). - First(&sess, sessionID).Error; err != nil { + q := ac.db.Where("user_id = ?", ac.userID) + 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: "会话不存在或无权访问"}) return } @@ -246,7 +313,7 @@ func (ac *agentConn) resumeSession(sessionID uint) { } // continueTask 在同一会话内追问(保留上下文) // Logan.Li -func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) { +func (ac *agentConn) continueTask(query, safetyMode string) { if ac.isRunning() { ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"}) return @@ -265,11 +332,11 @@ func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"}) return } - ac.runTask(sshCfg, ac.session, query, safetyMode, llmCfg, true) + ac.runTask(ac.session, query, safetyMode, llmCfg, true) } // 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() { ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"}) return @@ -287,14 +354,28 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe coreCfg := core.DefaultConfig() coreCfg.RoundOffset = roundOffset + coreCfg.Mode = ac.mode - engine, err := agentcli.NewEngine(agentcli.EngineConfig{ + engCfg := agentcli.EngineConfig{ + Mode: ac.mode, LLM: buildLLMConfigFromModel(llmCfg), - SSH: sshCfg, SafetyMode: parseSafetyMode(safetyMode), MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID), 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 { ac.write(wsEvent{Type: "error", Message: "引擎初始化失败: " + err.Error()}) 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) - serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s", - ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS) + targetInfo := ac.buildTargetInfo() msg := "任务已开始" if isContinue { @@ -326,7 +406,7 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe engine.Close() }() 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 { ac.convHistory = result.History 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 func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) { confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano()) 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}) ch := make(chan bool, 1) diff --git a/backend/controller/agent_cli_api.go b/backend/controller/agent_cli_api.go index e05f655..ee578fb 100644 --- a/backend/controller/agent_cli_api.go +++ b/backend/controller/agent_cli_api.go @@ -10,10 +10,11 @@ import ( "ezops/middleware" "ezops/model" + "ezops/pkg/agentcli" ) // 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) { uid := middleware.GetUserID(c) 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) + 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 != "" { 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 q.Count(&total) diff --git a/backend/controller/agent_cli_kubernetes.go b/backend/controller/agent_cli_kubernetes.go new file mode 100644 index 0000000..d14b275 --- /dev/null +++ b/backend/controller/agent_cli_kubernetes.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/billing.go b/backend/controller/billing.go index d3b8073..d1d994e 100644 --- a/backend/controller/billing.go +++ b/backend/controller/billing.go @@ -34,6 +34,9 @@ func (h *BillingHandler) ListBillings(c *gin.Context) { if accountName := c.Query("account_name"); 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 != "" { query = query.Where("billing_date >= ?", startDate) } diff --git a/backend/controller/billing_sync.go b/backend/controller/billing_sync.go new file mode 100644 index 0000000..2a8ad74 --- /dev/null +++ b/backend/controller/billing_sync.go @@ -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}) +} \ No newline at end of file diff --git a/backend/controller/cloud_sync.go b/backend/controller/cloud_sync.go index 823cfcc..1eb8bdb 100644 --- a/backend/controller/cloud_sync.go +++ b/backend/controller/cloud_sync.go @@ -7,6 +7,7 @@ import ( "ezops/cloud" "ezops/model" + "ezops/pkg/crypto" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -14,11 +15,12 @@ import ( // CloudSyncHandler 云平台同步处理器 type CloudSyncHandler struct { - db *gorm.DB + db *gorm.DB + crypt *crypto.SecretBox } -func NewCloudSyncHandler(db *gorm.DB) *CloudSyncHandler { - return &CloudSyncHandler{db: db} +func NewCloudSyncHandler(db *gorm.DB, masterKey string) *CloudSyncHandler { + return &CloudSyncHandler{db: db, crypt: crypto.NewSecretBox(masterKey)} } // SyncRegions 同步云账号的区域列表到本地 regions 表 @@ -167,8 +169,12 @@ func (h *CloudSyncHandler) DiscoverInstances(c *gin.Context) { } // importRequest 导入主机请求 -type importRequest struct { - Instances []cloud.CloudInstance `json:"instances"` +type importInstancesRequest struct { + 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 将发现的云主机实例导入为本地服务器资产 @@ -182,12 +188,32 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) { return } - var req importRequest + var req importInstancesRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"}) 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 skipped := 0 @@ -216,9 +242,16 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) { regionID = ®ion.ID } - osName := "linux" - if inst.OSType != "" { - osName = inst.OSType + osName := inst.OSType + osType := normalizeImportOSType(inst.OSType, inst.InstanceName) + protocol := "ssh" + username := "root" + if osType == "windows" { + protocol = "rdp" + username = "Administrator" + } + if osName == "" { + osName = osType } status := "stopped" if inst.Status == "Running" { @@ -245,8 +278,12 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) { Hostname: hostname, IP: ipAddr, PublicIP: inst.PublicIP, - Port: 22, - Username: "root", + Port: defaultPort, + Username: username, + AuthType: "password", + OSType: osType, + Protocol: protocol, + RDPPort: 3389, OS: osName, CPU: inst.CPU, Memory: int64(inst.Memory), @@ -255,6 +292,11 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) { Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")", 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 { imported++ diff --git a/backend/controller/credential.go b/backend/controller/credential.go index 21a28d3..a6d6b6c 100644 --- a/backend/controller/credential.go +++ b/backend/controller/credential.go @@ -46,6 +46,25 @@ func buildContent(req model.CreateSecretRequest) (string, string, error) { } b, _ := json.Marshal(data) 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": data := map[string]string{ "server": req.DockerServer, @@ -90,6 +109,20 @@ func buildContent(req model.CreateSecretRequest) (string, string, error) { 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 == "" { + security = "nla" + } + account := username + if domain != "" && domain != "" { + account = username + "@" + domain + } + return fmt.Sprintf("Windows RDP %s / %s", account, strings.ToUpper(security)) +} + // ────── List ────── func (h *CredentialHandler) ListCredentials(c *gin.Context) { @@ -117,9 +150,13 @@ func (h *CredentialHandler) ListCredentials(c *gin.Context) { db.Count(&total) page := query.Page - if page < 1 { page = 1 } + if page < 1 { + page = 1 + } pageSize := query.PageSize - if pageSize < 1 || pageSize > 100 { pageSize = 20 } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } var creds []model.Credential 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) isActive := true - if req.IsActive != nil { isActive = *req.IsActive } + if req.IsActive != nil { + isActive = *req.IsActive + } cred := model.Credential{ Name: req.Name, @@ -219,7 +258,9 @@ func (h *CredentialHandler) GetCredential(c *gin.Context) { json.Unmarshal([]byte(plain), &contentData) 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{ "id": cred.ID, @@ -255,11 +296,21 @@ func (h *CredentialHandler) UpdateCredential(c *gin.Context) { updates := map[string]interface{}{} - if req.Name != nil { updates["name"] = *req.Name } - if req.VariableName != nil { 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 req.Name != nil { + updates["name"] = *req.Name + } + if req.VariableName != nil { + 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 { b, _ := json.Marshal(req.Tags) @@ -272,7 +323,9 @@ func (h *CredentialHandler) UpdateCredential(c *gin.Context) { oldPlain, _ := h.crypt.Decrypt(cred.Content) var old map[string]interface{} json.Unmarshal([]byte(oldPlain), &old) - if old == nil { old = map[string]interface{}{} } + if old == nil { + old = map[string]interface{}{} + } mergeUpdate(req, old) @@ -297,6 +350,7 @@ func (h *CredentialHandler) UpdateCredential(c *gin.Context) { func hasTypeFieldUpdate(req model.UpdateSecretRequest) bool { 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.TLSCert != nil || req.TLSKey != nil || req.TLSCA != 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{}) { - if req.SSHPrivateKey != nil { old["private_key"] = *req.SSHPrivateKey } - if req.SSHPublicKey != nil { old["public_key"] = *req.SSHPublicKey } - if req.SSHUser != nil { old["ssh_user"] = *req.SSHUser } - if req.SSHPort != nil { old["ssh_port"] = *req.SSHPort } - if req.SSHPassword != nil { old["password"] = *req.SSHPassword } - 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 } + if req.SSHPrivateKey != nil { + old["private_key"] = *req.SSHPrivateKey + } + if req.SSHPublicKey != nil { + old["public_key"] = *req.SSHPublicKey + } + if req.SSHUser != nil { + old["ssh_user"] = *req.SSHUser + } + if req.SSHPort != nil { + old["ssh_port"] = *req.SSHPort + } + if req.SSHPassword != nil { + old["password"] = *req.SSHPassword + } + if req.RDPUsername != nil { + old["username"] = *req.RDPUsername + } + if req.RDPPassword != nil { + old["password"] = *req.RDPPassword + } + if req.RDPDomain != nil { + old["domain"] = *req.RDPDomain + } + 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 { switch stype { case "ssh": 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": return fmt.Sprintf("%v", data["server"]) case "aksk": @@ -391,10 +513,14 @@ func (h *CredentialHandler) GetTags(c *gin.Context) { for _, r := range rows { var tags []string 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)) - 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}) } diff --git a/backend/controller/credential_assign.go b/backend/controller/credential_assign.go index 8fed486..0f2977e 100644 --- a/backend/controller/credential_assign.go +++ b/backend/controller/credential_assign.go @@ -74,6 +74,23 @@ func applyCredential(cred model.Credential, plain string, server *model.Server) server.SSHKey = privateKey server.AuthType = "key" 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 { server.Password = plain server.AuthType = "password" diff --git a/backend/controller/rdp.go b/backend/controller/rdp.go new file mode 100644 index 0000000..51f7f7b --- /dev/null +++ b/backend/controller/rdp.go @@ -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 +} diff --git a/backend/controller/rdp_cred.go b/backend/controller/rdp_cred.go new file mode 100644 index 0000000..f7c8a73 --- /dev/null +++ b/backend/controller/rdp_cred.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/rdp_file.go b/backend/controller/rdp_file.go new file mode 100644 index 0000000..a6478d6 --- /dev/null +++ b/backend/controller/rdp_file.go @@ -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 +} \ No newline at end of file diff --git a/backend/controller/rdp_live.go b/backend/controller/rdp_live.go new file mode 100644 index 0000000..d39d41b --- /dev/null +++ b/backend/controller/rdp_live.go @@ -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) + } +} diff --git a/backend/controller/rdp_session.go b/backend/controller/rdp_session.go new file mode 100644 index 0000000..dac402e --- /dev/null +++ b/backend/controller/rdp_session.go @@ -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"}) +} diff --git a/backend/controller/server.go b/backend/controller/server.go index 47a0756..a3167e0 100644 --- a/backend/controller/server.go +++ b/backend/controller/server.go @@ -119,6 +119,7 @@ func (h *ServerHandler) ListServers(c *gin.Context) { // 填充 has_auth 字段(密码和SSH密钥在JSON中隐藏,但前端需知道是否有凭据) // Logan.Li for i := range servers { + normalizeServerRemoteAccess(&servers[i]) servers[i].HasAuth = servers[i].Password != "" || servers[i].SSHKey != "" } @@ -162,6 +163,9 @@ func (h *ServerHandler) GetServer(c *gin.Context) { return } + normalizeServerRemoteAccess(&server) + server.HasAuth = server.Password != "" || server.SSHKey != "" + c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "success", diff --git a/backend/controller/server_batch.go b/backend/controller/server_batch.go index c06da7f..f71237d 100644 --- a/backend/controller/server_batch.go +++ b/backend/controller/server_batch.go @@ -13,8 +13,7 @@ import ( // 批量操作域:批量导入 + 批量删除 // Logan.Li -// BatchCreateServers 批量导入服务器 // Logan.Li -// 接收 {servers:[...], region_id, auth_type, credential_id},逐条校验并创建,跳过重复(主机名或IP已存在) +// BatchCreateServers 批量导入服务器,Linux 默认 SSH 凭据,Windows 默认 RDP 凭据 // Logan.Li func (h *ServerHandler) BatchCreateServers(c *gin.Context) { var req model.BatchCreateServerRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -30,24 +29,19 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) { authType = "password" } - // 如果指定了凭据,提前解密(plain 在循环内由 applyCredential 解析填充) // Logan.Li - var ( - hasCred bool - cred model.Credential - credPlain string - ) - if req.CredentialID != nil && *req.CredentialID > 0 { - if err := h.db.First(&cred, *req.CredentialID).Error; err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "凭据不存在"}) - return - } - p, err := h.crypt.Decrypt(cred.Content) - if err != nil { - c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "凭据解密失败"}) - return - } - credPlain = p - hasCred = true + 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 } result := model.BatchCreateResult{Errors: []string{}} @@ -68,13 +62,30 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) { 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 if port == 0 { port = 22 } + rdpPort := item.RDPPort + if rdpPort == 0 { + rdpPort = 3389 + } username := strings.TrimSpace(item.Username) if username == "" { - username = "root" + if protocol == "rdp" { + username = "Administrator" + } else { + username = "root" + } } server := model.Server{ @@ -83,6 +94,9 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) { Port: port, Username: username, AuthType: authType, // 无凭证时用请求 authType(默认 password) + OSType: osType, + Protocol: protocol, + RDPPort: rdpPort, OS: item.OS, CPU: item.CPU, Memory: item.Memory * 1024, // GB → MB @@ -91,8 +105,10 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) { RegionID: req.RegionID, Status: "stopped", } - if hasCred { - applyCredential(cred, credPlain, &server) // 有凭证则覆盖认证字段(ssh→私钥/ssh_user/ssh_port) // Logan.Li + 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 { result.Failed++ diff --git a/backend/controller/server_import_helpers.go b/backend/controller/server_import_helpers.go new file mode 100644 index 0000000..aaa1e12 --- /dev/null +++ b/backend/controller/server_import_helpers.go @@ -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" +} diff --git a/backend/controller/session_command.go b/backend/controller/session_command.go index 131d339..382e43b 100644 --- a/backend/controller/session_command.go +++ b/backend/controller/session_command.go @@ -68,8 +68,9 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) { // 联表带出会话录制大小,前端可判断能否回放 // Logan.Li var items []model.SessionCommandItem listQuery := h.db.Table("sys_session_commands AS sc"). - Select("sc.*, COALESCE(ts.record_size, 0) AS session_record_size"). - Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id") + 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 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 { listQuery = listQuery.Where("sc.session_id = ?", req.SessionID) } diff --git a/backend/controller/webshell.go b/backend/controller/webshell.go index 4aacdcd..dff6ca4 100644 --- a/backend/controller/webshell.go +++ b/backend/controller/webshell.go @@ -64,6 +64,7 @@ func (r *asciicastRecorder) writeHeader() { "width": 80, "height": 24, "timestamp": r.startTime.Unix(), + "aiops_input_recorded": false, "env": map[string]string{ "SHELL": "/bin/bash", "TERM": "xterm-256color", @@ -396,10 +397,6 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { case 0: if msg.Data != "" { stdinPipe.Write([]byte(msg.Data)) - // 录入用户输入,回放可见键入内容 // Logan.Li - if recorder.Len() < maxRecordSize { - recorder.WriteRow([]byte(msg.Data)) - } // Command extraction from stdin 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 { now := time.Now() cmd := model.SessionCommand{ - SessionID: sessionID, - ServerID: serverID, - ServerName: serverName, - Username: username, - Command: command, - Timestamp: now, + SessionID: sessionID, + SessionType: "terminal", + ServerID: serverID, + ServerName: serverName, + Username: username, + Command: command, + Timestamp: now, } if err := db.Create(&cmd).Error; err != nil { log.Printf("[WebShell] Command record failed: %v", err) diff --git a/backend/dto/dto.go b/backend/dto/dto.go index b531a0a..6de22f2 100644 --- a/backend/dto/dto.go +++ b/backend/dto/dto.go @@ -8,50 +8,58 @@ import "time" // CreateServerRequest 创建服务器 type CreateServerRequest struct { - Hostname string `json:"hostname" binding:"required"` - IP string `json:"ip" binding:"required"` - PublicIP string `json:"public_ip"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - SSHKey string `json:"ssh_key"` - AuthType string `json:"auth_type"` - OS string `json:"os"` - OSVersion string `json:"os_version"` - Arch string `json:"arch"` - CPU int `json:"cpu"` - Memory int64 `json:"memory"` - Disk int64 `json:"disk"` - RegionID *uint `json:"region_id"` - CloudID *uint `json:"cloud_id"` - Tags string `json:"tags"` - Description string `json:"description"` + Hostname string `json:"hostname" binding:"required"` + IP string `json:"ip" binding:"required"` + PublicIP string `json:"public_ip"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + SSHKey string `json:"ssh_key"` + AuthType string `json:"auth_type"` + CredentialID *uint `json:"credential_id"` + OSType string `json:"os_type"` + Protocol string `json:"protocol"` + RDPPort int `json:"rdp_port"` + OS string `json:"os"` + OSVersion string `json:"os_version"` + Arch string `json:"arch"` + CPU int `json:"cpu"` + Memory int64 `json:"memory"` + Disk int64 `json:"disk"` + RegionID *uint `json:"region_id"` + CloudID *uint `json:"cloud_id"` + Tags string `json:"tags"` + Description string `json:"description"` } // UpdateServerRequest 更新服务器 — 部分更新 (使用 map) type UpdateServerRequest struct { - Hostname *string `json:"hostname"` - IP *string `json:"ip"` - PublicIP *string `json:"public_ip"` - Port *int `json:"port"` - Username *string `json:"username"` - Password *string `json:"password"` - SSHKey *string `json:"ssh_key"` - AuthType *string `json:"auth_type"` - OS *string `json:"os"` - OSVersion *string `json:"os_version"` - Arch *string `json:"arch"` - CPU *int `json:"cpu"` - Memory *int64 `json:"memory"` - Disk *int64 `json:"disk"` - Status *string `json:"status"` - CPUUsage *float64 `json:"cpu_usage"` - MemoryUsage *float64 `json:"memory_usage"` - DiskUsage *float64 `json:"disk_usage"` - RegionID *uint `json:"region_id"` - CloudID *uint `json:"cloud_id"` - Tags *string `json:"tags"` - Description *string `json:"description"` + Hostname *string `json:"hostname"` + IP *string `json:"ip"` + PublicIP *string `json:"public_ip"` + Port *int `json:"port"` + Username *string `json:"username"` + Password *string `json:"password"` + SSHKey *string `json:"ssh_key"` + AuthType *string `json:"auth_type"` + CredentialID *uint `json:"credential_id"` + OSType *string `json:"os_type"` + Protocol *string `json:"protocol"` + RDPPort *int `json:"rdp_port"` + OS *string `json:"os"` + OSVersion *string `json:"os_version"` + Arch *string `json:"arch"` + CPU *int `json:"cpu"` + Memory *int64 `json:"memory"` + Disk *int64 `json:"disk"` + Status *string `json:"status"` + CPUUsage *float64 `json:"cpu_usage"` + MemoryUsage *float64 `json:"memory_usage"` + DiskUsage *float64 `json:"disk_usage"` + RegionID *uint `json:"region_id"` + CloudID *uint `json:"cloud_id"` + Tags *string `json:"tags"` + Description *string `json:"description"` } // ServerListItem 服务器列表项 (精简) @@ -85,6 +93,12 @@ type CreateCredentialRequest struct { SSHPort int `json:"ssh_port"` 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 DockerServer string `json:"docker_server"` DockerUsername string `json:"docker_username"` @@ -113,6 +127,11 @@ type UpdateCredentialRequest struct { SSHPort *int `json:"ssh_port"` 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"` DockerUsername *string `json:"docker_username"` DockerPassword *string `json:"docker_password"` @@ -161,14 +180,14 @@ type UpdateAlertContactGroupRequest struct { // CreateAlertRuleRequest 创建告警规则 type CreateAlertRuleRequest struct { - Name string `json:"name" binding:"required"` - Metric string `json:"metric" binding:"required"` - Condition string `json:"condition" binding:"required"` // >, <, >=, <=, == + Name string `json:"name" binding:"required"` + Metric string `json:"metric" binding:"required"` + Condition string `json:"condition" binding:"required"` // >, <, >=, <=, == Threshold float64 `json:"threshold" binding:"required"` - Duration string `json:"duration"` // 30s, 1m, 5m - ContactIDs []uint `json:"contact_ids"` - Enabled bool `json:"enabled"` - Description string `json:"description"` + Duration string `json:"duration"` // 30s, 1m, 5m + ContactIDs []uint `json:"contact_ids"` + Enabled bool `json:"enabled"` + Description string `json:"description"` } // UpdateAlertRuleRequest 更新告警规则 diff --git a/backend/go.mod b/backend/go.mod index 5d289e0..de42129 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,19 +1,23 @@ module ezops -go 1.24.0 +go 1.25.0 require ( github.com/creack/pty v1.1.24 github.com/gin-gonic/gin v1.9.1 github.com/glebarez/sqlite v1.11.0 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/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 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/pkg/sftp v1.13.10 github.com/redis/go-redis/v9 v9.7.0 + github.com/robfig/cron/v3 v3.0.1 github.com/spf13/viper v1.18.2 go.uber.org/zap v1.26.0 golang.org/x/crypto v0.46.0 @@ -32,13 +36,13 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.7.0 // 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/glebarez/go-sqlite v1.21.2 // 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/universal-translator v0.18.1 // 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/hashicorp/hcl 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/magiconair/properties v1.8.7 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // 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/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 6fea326..7f8333f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 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/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 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/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 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/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-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/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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/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-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.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 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.15.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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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.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-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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend/main.go b/backend/main.go index dd20d7b..1ed8774 100644 --- a/backend/main.go +++ b/backend/main.go @@ -19,6 +19,7 @@ import ( "ezops/seed" "ezops/pkg/appdir" "ezops/pkg/auditcleaner" + "ezops/pkg/billingsync" "ezops/pkg/database" snmppkg "ezops/pkg/snmp" "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 setupFrontend(r) diff --git a/backend/model/agent_cli.go b/backend/model/agent_cli.go index f7ca633..2593a08 100644 --- a/backend/model/agent_cli.go +++ b/backend/model/agent_cli.go @@ -9,9 +9,15 @@ import "time" type AgentCLISession struct { ID uint `gorm:"primarykey" json:"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"` // 主机名快照 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 截断) Status string `gorm:"size:32;default:running" json:"status"` // running/done/aborted/error Provider string `gorm:"size:50" json:"provider"` // LLM 提供商 diff --git a/backend/model/credential.go b/backend/model/credential.go index a4f7c3a..06d7c93 100644 --- a/backend/model/credential.go +++ b/backend/model/credential.go @@ -9,27 +9,29 @@ import ( // 核心加密字段 content 存储类型特有数据(JSON),API 不返回 // // 类型与内容 JSON 映射: -// ssh → {"private_key":"...","public_key":"...","ssh_user":"...","ssh_port":22} -// docker → {"server":"...","username":"...","password":"..."} -// tls → {"cert":"...","key":"...","ca":"..."} -// aksk → {"provider":"...","access_key":"...","secret_key":"...","region":"..."} -// database → {"db_type":"...","host":"...","port":3306,"db_name":"...","username":"...","password":"..."} -// api → {"key":"...","endpoint":"..."} -// generic → {"key":"...","value":"..."} +// +// ssh → {"private_key":"...","public_key":"...","ssh_user":"...","ssh_port":22} +// rdp → {"username":"...","password":"...","domain":"...","security":"nla"} +// docker → {"server":"...","username":"...","password":"..."} +// tls → {"cert":"...","key":"...","ca":"..."} +// aksk → {"provider":"...","access_key":"...","secret_key":"...","region":"..."} +// database → {"db_type":"...","host":"...","port":3306,"db_name":"...","username":"...","password":"..."} +// api → {"key":"...","endpoint":"..."} +// generic → {"key":"...","value":"..."} type Credential struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` // 凭据名称 - 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 - Summary string `gorm:"size:255" json:"summary,omitempty"` // 关键信息摘要(脱敏展示) - Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回 - Tags string `gorm:"type:text" json:"tags,omitempty"` // JSON 数组: ["生产环境","阿里云"] - Description string `gorm:"size:500" json:"description,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` // 过期时间 - IsActive bool `gorm:"default:true" json:"is_active"` // 启用/禁用 - EnvPrefix string `gorm:"size:32" json:"env_prefix,omitempty"` // 引用 ID(可复制) - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` // 凭据名称 + 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/rdp/docker/tls/aksk/database/api/generic + Summary string `gorm:"size:255" json:"summary,omitempty"` // 关键信息摘要(脱敏展示) + Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回 + Tags string `gorm:"type:text" json:"tags,omitempty"` // JSON 数组: ["生产环境","阿里云"] + Description string `gorm:"size:500" json:"description,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` // 过期时间 + IsActive bool `gorm:"default:true" json:"is_active"` // 启用/禁用 + EnvPrefix string `gorm:"size:32" json:"env_prefix,omitempty"` // 引用 ID(可复制) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Credential) TableName() string { @@ -47,6 +49,14 @@ type SSHSecretContent struct { 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 type DockerSecretContent struct { Server string `json:"server"` @@ -95,13 +105,13 @@ type GenericSecretContent struct { // CreateSecretRequest 创建凭据请求 type CreateSecretRequest struct { - Name string `json:"name" binding:"required"` - VariableName string `json:"variable_name"` - SecretType string `json:"secret_type" binding:"required,oneof=ssh docker tls aksk database api generic"` - Tags []string `json:"tags"` - Description string `json:"description"` + Name string `json:"name" binding:"required"` + VariableName string `json:"variable_name"` + SecretType string `json:"secret_type" binding:"required,oneof=ssh rdp docker tls aksk database api generic"` + Tags []string `json:"tags"` + Description string `json:"description"` ExpiresAt *time.Time `json:"expires_at"` - IsActive *bool `json:"is_active"` + IsActive *bool `json:"is_active"` // 类型特有字段(明文,后端组装成 JSON 加密存储) // SSH @@ -110,6 +120,11 @@ type CreateSecretRequest struct { SSHUser string `json:"ssh_user,omitempty"` SSHPort int `json:"ssh_port,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 DockerServer string `json:"docker_server,omitempty"` DockerUsername string `json:"docker_username,omitempty"` @@ -140,39 +155,43 @@ type CreateSecretRequest struct { // UpdateSecretRequest 更新凭据请求 type UpdateSecretRequest struct { - Name *string `json:"name"` - VariableName *string `json:"variable_name"` - Tags []string `json:"tags"` - Description *string `json:"description"` + Name *string `json:"name"` + VariableName *string `json:"variable_name"` + Tags []string `json:"tags"` + Description *string `json:"description"` ExpiresAt *time.Time `json:"expires_at"` - IsActive *bool `json:"is_active"` + IsActive *bool `json:"is_active"` // 类型特有字段 — 传了才更新 - SSHPrivateKey *string `json:"ssh_private_key,omitempty"` - SSHPublicKey *string `json:"ssh_public_key,omitempty"` - SSHUser *string `json:"ssh_user,omitempty"` - SSHPort *int `json:"ssh_port,omitempty"` - SSHPassword *string `json:"ssh_password,omitempty"` + SSHPrivateKey *string `json:"ssh_private_key,omitempty"` + SSHPublicKey *string `json:"ssh_public_key,omitempty"` + SSHUser *string `json:"ssh_user,omitempty"` + SSHPort *int `json:"ssh_port,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"` DockerUsername *string `json:"docker_username,omitempty"` DockerPassword *string `json:"docker_password,omitempty"` - TLSCert *string `json:"tls_cert,omitempty"` - TLSKey *string `json:"tls_key,omitempty"` - TLSCA *string `json:"tls_ca,omitempty"` - AKSKProvider *string `json:"aksk_provider,omitempty"` - AKSKAccessKey *string `json:"aksk_access_key,omitempty"` - AKSKSecretKey *string `json:"aksk_secret_key,omitempty"` - AKSKRegion *string `json:"aksk_region,omitempty"` - DBType *string `json:"db_type,omitempty"` - DBHost *string `json:"db_host,omitempty"` - DBPort *int `json:"db_port,omitempty"` - DBName *string `json:"db_name,omitempty"` - DBUsername *string `json:"db_username,omitempty"` - DBPassword *string `json:"db_password,omitempty"` - APIKey *string `json:"api_key,omitempty"` - APIEndpoint *string `json:"api_endpoint,omitempty"` - GenericKey *string `json:"generic_key,omitempty"` - GenericValue *string `json:"generic_value,omitempty"` + TLSCert *string `json:"tls_cert,omitempty"` + TLSKey *string `json:"tls_key,omitempty"` + TLSCA *string `json:"tls_ca,omitempty"` + AKSKProvider *string `json:"aksk_provider,omitempty"` + AKSKAccessKey *string `json:"aksk_access_key,omitempty"` + AKSKSecretKey *string `json:"aksk_secret_key,omitempty"` + AKSKRegion *string `json:"aksk_region,omitempty"` + DBType *string `json:"db_type,omitempty"` + DBHost *string `json:"db_host,omitempty"` + DBPort *int `json:"db_port,omitempty"` + DBName *string `json:"db_name,omitempty"` + DBUsername *string `json:"db_username,omitempty"` + DBPassword *string `json:"db_password,omitempty"` + APIKey *string `json:"api_key,omitempty"` + APIEndpoint *string `json:"api_endpoint,omitempty"` + GenericKey *string `json:"generic_key,omitempty"` + GenericValue *string `json:"generic_value,omitempty"` } // SecretListResponse 列表返回(内容脱敏) diff --git a/backend/model/rdp_session.go b/backend/model/rdp_session.go new file mode 100644 index 0000000..79c931f --- /dev/null +++ b/backend/model/rdp_session.go @@ -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"` +} diff --git a/backend/model/server.go b/backend/model/server.go index b5efa07..8feeab3 100644 --- a/backend/model/server.go +++ b/backend/model/server.go @@ -2,42 +2,44 @@ package model import ( "time" - ) // Server 服务器模型 type Server struct { - ID uint `gorm:"primarykey" json:"id"` - Hostname string `gorm:"size:128;not null" json:"hostname"` - IP string `gorm:"size:64;not null" json:"ip"` - PublicIP string `gorm:"size:64" json:"public_ip"` - Port int `gorm:"default:22" json:"port"` - Username string `gorm:"size:64;default:root" json:"username"` - Password string `gorm:"size:255" json:"-"` - SSHKey string `gorm:"type:text" json:"-"` - AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key - OS string `gorm:"size:64" json:"os"` - Kernel string `gorm:"size:64" json:"kernel"` - OSVersion string `gorm:"size:64" json:"os_version"` - Arch string `gorm:"size:32" json:"arch"` - CPU int `json:"cpu"` - Memory int64 `json:"memory"` // MB - Disk int64 `json:"disk"` // GB - Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - DiskUsage float64 `json:"disk_usage"` - Uptime int64 `json:"uptime"` // seconds - AgentVersion string `gorm:"size:32" json:"agent_version"` - AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline - HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li - RegionID *uint `json:"region_id"` - CloudID *uint `json:"cloud_id"` - Tags string `gorm:"type:text" json:"tags"` // JSON - 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"` + ID uint `gorm:"primarykey" json:"id"` + Hostname string `gorm:"size:128;not null" json:"hostname"` + IP string `gorm:"size:64;not null" json:"ip"` + PublicIP string `gorm:"size:64" json:"public_ip"` + Port int `gorm:"default:22" json:"port"` + Username string `gorm:"size:64;default:root" json:"username"` + Password string `gorm:"size:255" json:"-"` + SSHKey string `gorm:"type:text" json:"-"` + AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key + OSType string `gorm:"size:32;default:linux" json:"os_type"` // 操作系统类型:linux/windows // Logan.Li + Protocol string `gorm:"size:32;default:ssh" json:"protocol"` // 远程协议:ssh/rdp // Logan.Li + RDPPort int `gorm:"default:3389" json:"rdp_port"` // Windows RDP 端口 // Logan.Li + OS string `gorm:"size:64" json:"os"` + Kernel string `gorm:"size:64" json:"kernel"` + OSVersion string `gorm:"size:64" json:"os_version"` + Arch string `gorm:"size:32" json:"arch"` + CPU int `json:"cpu"` + Memory int64 `json:"memory"` // MB + Disk int64 `json:"disk"` // GB + Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + Uptime int64 `json:"uptime"` // seconds + AgentVersion string `gorm:"size:32" json:"agent_version"` + AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline + HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li + RegionID *uint `json:"region_id"` + CloudID *uint `json:"cloud_id"` + Tags string `gorm:"type:text" json:"tags"` // JSON + 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"` @@ -51,15 +53,15 @@ func (Server) TableName() string { // Cloud 云平台模型 type Cloud struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:64;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Type string `gorm:"size:32;default:public" json:"type"` // public, private, hybrid - Status string `gorm:"size:32;default:active" json:"status"` - Website string `gorm:"size:255" json:"website"` - Description string `gorm:"size:500" json:"description"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Type string `gorm:"size:32;default:public" json:"type"` // public, private, hybrid + Status string `gorm:"size:32;default:active" json:"status"` + Website string `gorm:"size:255" json:"website"` + Description string `gorm:"size:500" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -69,18 +71,18 @@ func (Cloud) TableName() string { // CloudAccount 云账号模型 type CloudAccount struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure - AccessKey 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 - RegionCount int `gorm:"default:0" json:"region_count"` - ServerCount int `gorm:"default:0" json:"server_count"` - MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计 - LastSyncAt *time.Time `json:"last_sync_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure + AccessKey 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 + RegionCount int `gorm:"default:0" json:"region_count"` + ServerCount int `gorm:"default:0" json:"server_count"` + MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计 + LastSyncAt *time.Time `json:"last_sync_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -90,16 +92,16 @@ func (CloudAccount) TableName() string { // Region 区域模型 type Region struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:64;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Provider string `gorm:"size:64;not null" json:"provider"` - Location string `gorm:"size:128" json:"location"` - Status string `gorm:"size:32;default:active" json:"status"` - Endpoint string `gorm:"size:255" json:"endpoint"` - VPCCount int `gorm:"default:0" json:"vpc_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Provider string `gorm:"size:64;not null" json:"provider"` + Location string `gorm:"size:128" json:"location"` + Status string `gorm:"size:32;default:active" json:"status"` + Endpoint string `gorm:"size:255" json:"endpoint"` + VPCCount int `gorm:"default:0" json:"vpc_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -109,17 +111,17 @@ func (Region) TableName() string { // Domain 域名模型 type Domain struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:255;uniqueIndex;not null" json:"name"` - Registrar string `gorm:"size:128" json:"registrar"` - Status string `gorm:"size:32;default:active" json:"status"` // active, expired, pending - ExpireDate *time.Time `json:"expire_date"` - DNSProvider string `gorm:"size:128" json:"dns_provider"` - RecordCount int `gorm:"default:0" json:"record_count"` - SSLEnabled bool `gorm:"default:false" json:"ssl_enabled"` - SSLExpireDate *time.Time `json:"ssl_expire_date"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:255;uniqueIndex;not null" json:"name"` + Registrar string `gorm:"size:128" json:"registrar"` + Status string `gorm:"size:32;default:active" json:"status"` // active, expired, pending + ExpireDate *time.Time `json:"expire_date"` + DNSProvider string `gorm:"size:128" json:"dns_provider"` + RecordCount int `gorm:"default:0" json:"record_count"` + SSLEnabled bool `gorm:"default:false" json:"ssl_enabled"` + SSLExpireDate *time.Time `json:"ssl_expire_date"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -129,46 +131,52 @@ func (Domain) TableName() string { // CreateServerRequest 创建服务器请求 type CreateServerRequest struct { - Hostname string `json:"hostname" binding:"required"` - IP string `json:"ip" binding:"required,ip"` - PublicIP string `json:"public_ip" binding:"omitempty,ip"` - Port int `json:"port" binding:"omitempty,min=1,max=65535"` - Username string `json:"username"` - Password string `json:"password"` - SSHKey string `json:"ssh_key"` - AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` - CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 - OS string `json:"os"` - CPU int `json:"cpu"` - Memory int64 `json:"memory"` - Disk int64 `json:"disk"` - Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"` - RegionID *uint `json:"region_id"` - CloudID *uint `json:"cloud_id"` - Tags string `json:"tags"` - Description string `json:"description"` + Hostname string `json:"hostname" binding:"required"` + IP string `json:"ip" binding:"required,ip"` + PublicIP string `json:"public_ip" binding:"omitempty,ip"` + Port int `json:"port" binding:"omitempty,min=1,max=65535"` + Username string `json:"username"` + Password string `json:"password"` + SSHKey string `json:"ssh_key"` + AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` + CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 + OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li + Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li + RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li + OS string `json:"os"` + CPU int `json:"cpu"` + Memory int64 `json:"memory"` + Disk int64 `json:"disk"` + Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"` + RegionID *uint `json:"region_id"` + CloudID *uint `json:"cloud_id"` + Tags string `json:"tags"` + Description string `json:"description"` } // UpdateServerRequest 更新服务器请求 type UpdateServerRequest struct { - Hostname string `json:"hostname"` - IP string `json:"ip" binding:"omitempty,ip"` - PublicIP string `json:"public_ip" binding:"omitempty,ip"` - Port int `json:"port" binding:"omitempty,min=1,max=65535"` - Username string `json:"username"` - Password string `json:"password"` - SSHKey string `json:"ssh_key"` - AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` - CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 - OS string `json:"os"` - CPU int `json:"cpu"` - Memory int64 `json:"memory"` - Disk int64 `json:"disk"` - Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"` - RegionID *uint `json:"region_id"` - CloudID *uint `json:"cloud_id"` - Tags string `json:"tags"` - Description string `json:"description"` + Hostname string `json:"hostname"` + IP string `json:"ip" binding:"omitempty,ip"` + PublicIP string `json:"public_ip" binding:"omitempty,ip"` + Port int `json:"port" binding:"omitempty,min=1,max=65535"` + Username string `json:"username"` + Password string `json:"password"` + SSHKey string `json:"ssh_key"` + AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` + CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 + OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li + Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li + RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li + OS string `json:"os"` + CPU int `json:"cpu"` + Memory int64 `json:"memory"` + Disk int64 `json:"disk"` + Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"` + RegionID *uint `json:"region_id"` + CloudID *uint `json:"cloud_id"` + Tags string `json:"tags"` + Description string `json:"description"` } // BatchServerItem 批量导入单条服务器 // Logan.Li @@ -177,6 +185,9 @@ type BatchServerItem struct { IP string `json:"ip"` Port int `json:"port"` 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"` CPU int `json:"cpu"` Memory int64 `json:"memory"` // GB(后端转 MB 存储) @@ -186,10 +197,12 @@ type BatchServerItem struct { // BatchCreateServerRequest 批量导入服务器请求 // Logan.Li type BatchCreateServerRequest struct { - Servers []BatchServerItem `json:"servers"` - RegionID *uint `json:"region_id"` - AuthType string `json:"auth_type"` - CredentialID *uint `json:"credential_id"` // 凭据ID,非空则解密后写入每台服务器 + Servers []BatchServerItem `json:"servers"` + RegionID *uint `json:"region_id"` + AuthType string `json:"auth_type"` + 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 @@ -217,9 +230,9 @@ type ServerStats struct { AvgMemory float64 `json:"avg_memory"` AvgDisk float64 `json:"avg_disk"` // 总资源概览(GB/核),用于仪表盘资源使用率卡片展示具体容量 // Logan.Li - TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计 - TotalMemory int64 `json:"total_memory"` // 内存总量 GB - UsedMemory int64 `json:"used_memory"` // 内存已用 GB - TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB - UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB + TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计 + TotalMemory int64 `json:"total_memory"` // 内存总量 GB + UsedMemory int64 `json:"used_memory"` // 内存已用 GB + TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB + UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB } diff --git a/backend/model/session_command.go b/backend/model/session_command.go index 2fe36b4..79f3363 100644 --- a/backend/model/session_command.go +++ b/backend/model/session_command.go @@ -4,16 +4,17 @@ import "time" // SessionCommand 会话命令记录(JumpServer 式命令审计) // Logan.Li type SessionCommand struct { - ID uint `gorm:"primarykey" json:"id"` - SessionID uint `gorm:"index" json:"session_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"` - Command string `gorm:"type:text" json:"command"` // 执行的原始命令 - OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li - Timestamp time.Time `json:"timestamp"` // 命令执行时间 - CreatedAt time.Time `json:"created_at"` + ID uint `gorm:"primarykey" json:"id"` + SessionID uint `gorm:"index" json:"session_id"` // 关联终端会话 + SessionType string `gorm:"size:32;default:terminal" json:"session_type"` // terminal/rdp,用于区分回放入口 // Logan.Li + 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"` + Command string `gorm:"type:text" json:"command"` // 执行的原始命令 + OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li + Timestamp time.Time `json:"timestamp"` // 命令执行时间 + CreatedAt time.Time `json:"created_at"` } func (SessionCommand) TableName() string { diff --git a/backend/pkg/agentcli/agentcli.go b/backend/pkg/agentcli/agentcli.go index 4195baa..c3c8511 100644 --- a/backend/pkg/agentcli/agentcli.go +++ b/backend/pkg/agentcli/agentcli.go @@ -18,13 +18,42 @@ import ( "ezops/pkg/agentcli/llm" "ezops/pkg/agentcli/safety" "ezops/pkg/agentcli/tools" + k8sclient "ezops/pkg/kubernetesclient" "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 { + Mode string // host | kubernetes LLM llm.Config // LLM 客户端配置(endpoint 已解析) - SSH remoteexec.SSHConfig // 目标主机 SSH 配置(含凭据) + SSH remoteexec.SSHConfig // 主机模式 SSH 配置 + Kubernetes *KubernetesTarget // Kubernetes 模式目标 SafetyMode safety.Mode // 安全模式(默认 balanced) Core *core.Config // ReAct 配置(nil 用默认) MemoryDir string // Evolver 记忆目录(空则 data/agentcli) @@ -47,25 +76,40 @@ func NewEngine(cfg EngineConfig) (*Engine, error) { if cfg.MemoryDir != "" { coreCfg.MemoryDir = cfg.MemoryDir } - mode := cfg.SafetyMode - if mode == "" { - mode = safety.ModeBalanced + safetyMode := cfg.SafetyMode + if safetyMode == "" { + safetyMode = safety.ModeBalanced } // LLM 客户端 client := llm.NewClient(cfg.LLM) - // 工具注册中心:ssh_execute(SSH 长连接)+ analyze_output + // 工具注册中心:按模式注册互斥工具集 // Logan.Li registry := tools.NewRegistry() - sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout) - if err != nil { - return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err) + runMode := NormalizeRunMode(cfg.Mode) + coreCfg.Mode = runMode + + 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()) // 安全控制器 - sc := safety.NewController(mode) + sc := safety.NewController(safetyMode) // Evolver(LLM 可用时启用学习;失败降级) 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 func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) } -// Run 执行任务(ReAct 循环);prior 为上一轮对话历史(不含 system),priorTokens 为累计 token // Logan.Li -func (e *Engine) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) { - return e.agent.Run(ctx, query, serverInfo, prior, priorTokens) +// Run 执行任务(ReAct 循环);targetInfo 为目标上下文摘要;prior 为上一轮对话历史(不含 system) // Logan.Li +func (e *Engine) Run(ctx context.Context, query, targetInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) { + return e.agent.Run(ctx, query, targetInfo, prior, priorTokens) } // EvolveStats 进化统计(供审计展示) // Logan.Li diff --git a/backend/pkg/agentcli/core/agent.go b/backend/pkg/agentcli/core/agent.go index b9e6238..b7ca228 100644 --- a/backend/pkg/agentcli/core/agent.go +++ b/backend/pkg/agentcli/core/agent.go @@ -61,6 +61,7 @@ type RunResult struct { // Config Agent 运行配置 // Logan.Li type Config struct { + Mode string // host | kubernetes MaxIterations int Temperature float64 ToolTimeout time.Duration @@ -135,7 +136,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M // 工具描述 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}} 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{ Role: "user", - Content: "【系统提示】请立即调用 ssh_execute 工具在目标主机执行命令获取实际结果,不要仅用文字描述计划。", + Content: fmt.Sprintf("【系统提示】请立即调用 %s 工具获取实际结果,不要仅用文字描述计划。", a.primaryExecTool()), }) continue } @@ -303,8 +304,8 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist success = false } - // 超长 ssh 输出自动分析,避免 token 爆炸 // Logan.Li - if tc.Function.Name == "ssh_execute" && success && len(output) > a.autoAnalyzeThreshold() { + // 超长命令输出自动分析,避免 token 爆炸 // Logan.Li + if (tc.Function.Name == "ssh_execute" || tc.Function.Name == "pod_execute") && success && len(output) > a.autoAnalyzeThreshold() { if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" { output = analyzed } @@ -364,6 +365,14 @@ func (a *Agent) roundNum(iter int) int { 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 func lastToolOutput(messages []llm.Message) string { for i := len(messages) - 1; i >= 0; i-- { diff --git a/backend/pkg/agentcli/prompt/prompt.go b/backend/pkg/agentcli/prompt/prompt.go index c8c99f8..5752e40 100644 --- a/backend/pkg/agentcli/prompt/prompt.go +++ b/backend/pkg/agentcli/prompt/prompt.go @@ -1,5 +1,4 @@ // Package prompt 构建 Agent CLI 的 System Prompt(五层架构 + 动态记忆注入)。 // Logan.Li -// 设计参考 opsxcli internal/agent/prompt,角色改写为 aiops Web 运维 Agent 语境。 package prompt import ( @@ -7,34 +6,59 @@ import ( "strings" ) -// BuildSystemPrompt 构建五层 System Prompt。 // Logan.Li -// - Layer 1: 角色定位(aiops 智能运维 Agent) -// - Layer 2: 安全规则(黑名单/高危确认/优先只读) -// - Layer 3: 工具说明(由 registry 动态生成) -// - Layer 4: 工作方式(ReAct 循环:思考→执行→观察→失败则换法重试→直到解决) -// - Layer 5: 记忆注入(Evolver 提供的历史经验) -// -// serverInfo:目标主机摘要;toolDescs:工具描述文本;evolveContext:历史经验。 -func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string { +const runModeKubernetes = "kubernetes" + +// normalizeMode 规范化运行模式(兼容旧 k8s,避免 import 循环) // Logan.Li +func normalizeMode(mode string) string { + if mode == "k8s" || mode == runModeKubernetes { + return runModeKubernetes + } + if mode == "" { + 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 // Layer 1: 角色 - sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," + - "类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n") - sb.WriteString("你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," + - "并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n") + if mode == runModeKubernetes { + sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级 Kubernetes 智能运维 Agent," + + "运行在 Web 页面上,通过 kubernetes_get / pod_logs / pod_execute / kubernetes_action 操作真实 Kubernetes 集群。\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) != "" { - sb.WriteString("【目标主机】\n" + serverInfo + "\n\n") + // 目标上下文 + if strings.TrimSpace(targetInfo) != "" { + label := "目标主机" + if mode == runModeKubernetes { + label = "目标集群上下文" + } + sb.WriteString(fmt.Sprintf("【%s】\n%s\n\n", label, targetInfo)) } // Layer 2: 安全规则 sb.WriteString("【安全规则】\n") sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n") - sb.WriteString("2. 高危操作(reboot、systemctl restart、kill、卸载软件等)需要用户确认后才能执行。\n") - sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n") - sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n\n") + sb.WriteString("2. 高危操作(删除 Pod、重启 Deployment、扩缩容、换镜像等)需要用户确认后才能执行。\n") + if mode == runModeKubernetes { + 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: 工具 if strings.TrimSpace(toolDescs) != "" { @@ -45,20 +69,24 @@ func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string { sb.WriteString("【工作方式(ReAct 循环)】\n") sb.WriteString("你采用「思考-行动-观察」循环解决任务:\n") sb.WriteString("1. 思考:分析当前状态,决定下一步。在回复中先用简短文字说明你的判断和计划。\n") - sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n") - sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n") - sb.WriteString("4. 循环:如果命令失败或问题未解决,分析错误原因,换一种方法重试,直到真正解决。不要因一次失败就放弃。\n") + if mode == runModeKubernetes { + sb.WriteString("2. 行动:优先 kubernetes_get / pod_logs;需要容器内信息时用 pod_execute;变更用 kubernetes_action(一次一步)。\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("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际命令输出判断," + - "禁止编造不存在的输出。命令超时或连接失败时,向用户说明并建议排查方向。\n\n") + sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际工具输出判断," + + "禁止编造不存在的输出。超时或 API 失败时,向用户说明并建议排查方向。\n\n") // Layer 5: 记忆注入 if strings.TrimSpace(evolveContext) != "" { sb.WriteString("【历史经验(来自自进化引擎)】\n" + evolveContext + "\n\n") } - // 输出要求 sb.WriteString("【输出要求】使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。") return sb.String() } @@ -70,4 +98,4 @@ func FormatToolDescriptions(items []struct{ Name, Desc string }) string { sb.WriteString(fmt.Sprintf("- %s: %s\n", it.Name, it.Desc)) } return sb.String() -} +} \ No newline at end of file diff --git a/backend/pkg/agentcli/tools/builtin.go b/backend/pkg/agentcli/tools/builtin.go index a9f35f8..eb95c60 100644 --- a/backend/pkg/agentcli/tools/builtin.go +++ b/backend/pkg/agentcli/tools/builtin.go @@ -20,7 +20,7 @@ func (t *AnalyzeOutputTool) Name() string { return "analyze_output" } // Description 给 LLM 的说明 // Logan.Li func (t *AnalyzeOutputTool) Description() string { return "分析一段(可能很长的)命令输出文本:统计行数、提取疑似错误/告警行、必要时截断保留首尾。" + - "当 ssh_execute 返回内容过长时调用此工具提炼关键信息再继续推理。" + "当 ssh_execute 或 pod_execute 返回内容过长时调用此工具提炼关键信息再继续推理。" } // Parameters 参数定义 // Logan.Li diff --git a/backend/pkg/agentcli/tools/kubernetes_action.go b/backend/pkg/agentcli/tools/kubernetes_action.go new file mode 100644 index 0000000..d57a158 --- /dev/null +++ b/backend/pkg/agentcli/tools/kubernetes_action.go @@ -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 +} \ No newline at end of file diff --git a/backend/pkg/agentcli/tools/kubernetes_get.go b/backend/pkg/agentcli/tools/kubernetes_get.go new file mode 100644 index 0000000..af28f24 --- /dev/null +++ b/backend/pkg/agentcli/tools/kubernetes_get.go @@ -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 +} \ No newline at end of file diff --git a/backend/pkg/agentcli/tools/pod_execute.go b/backend/pkg/agentcli/tools/pod_execute.go new file mode 100644 index 0000000..5977d7d --- /dev/null +++ b/backend/pkg/agentcli/tools/pod_execute.go @@ -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 +} \ No newline at end of file diff --git a/backend/pkg/agentcli/tools/pod_logs.go b/backend/pkg/agentcli/tools/pod_logs.go new file mode 100644 index 0000000..4128011 --- /dev/null +++ b/backend/pkg/agentcli/tools/pod_logs.go @@ -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 +} \ No newline at end of file diff --git a/backend/pkg/billingsync/sync.go b/backend/pkg/billingsync/sync.go new file mode 100644 index 0000000..3c40cb2 --- /dev/null +++ b/backend/pkg/billingsync/sync.go @@ -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 +} \ No newline at end of file diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 22421c7..8aed328 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -10,9 +10,9 @@ import ( "ezops/model" "ezops/pkg/appdir" + "github.com/glebarez/sqlite" "gorm.io/driver/mysql" "gorm.io/driver/postgres" - "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) @@ -91,8 +91,8 @@ func AutoMigrate(db *gorm.DB) error { &model.KubernetesIngress{}, &model.AlertRule{}, &model.AlertEvent{}, - &model.AlertContact{}, // 报警联系人 // Logan.Li - &model.AlertContactGroup{}, // 报警联系组 // Logan.Li + &model.AlertContact{}, // 报警联系人 // Logan.Li + &model.AlertContactGroup{}, // 报警联系组 // Logan.Li &model.AlertNotifyPolicy{}, // 告警集合通知策略 // Logan.Li &model.AlertNotifySettings{}, // 告警消息模版 // Logan.Li &model.NotifyChannel{}, @@ -110,9 +110,9 @@ func AutoMigrate(db *gorm.DB) error { &model.NavigationGroup{}, &model.NavigationLink{}, &model.AuditLog{}, - &model.AuditSettings{}, // 审计保留策略 // Logan.Li - &model.LoginLog{}, // 登录日志 // Logan.Li - &model.SystemTaskStatus{}, // 内置系统任务状态 // Logan.Li + &model.AuditSettings{}, // 审计保留策略 // Logan.Li + &model.LoginLog{}, // 登录日志 // Logan.Li + &model.SystemTaskStatus{}, // 内置系统任务状态 // Logan.Li &model.LDAPConfig{}, &model.ResourceModel{}, &model.CloudBilling{}, @@ -126,54 +126,55 @@ func AutoMigrate(db *gorm.DB) error { &model.AnsibleInventory{}, &model.LogSource{}, &model.LogEntry{}, - &model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li - &model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li - &model.SessionCommand{}, // 终端会话命令审计 // Logan.Li - &model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li - &model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li - &model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li - &model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li - &model.MonitorMetric{}, // 监控指标定义 // Logan.Li - &model.MetricValue{}, // 指标当前值 // Logan.Li - &model.MetricHistory{}, // 指标历史时序 // Logan.Li - &model.HostGroup{}, // 主机组 // Logan.Li - &model.SNMPDevice{}, // SNMP设备 // Logan.Li - &model.SNMPTemplate{}, // SNMP模板 // Logan.Li - &model.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li - &model.SNMPMetric{}, // SNMP采集指标 // Logan.Li - &model.SNMPInterface{}, // SNMP接口信息 // Logan.Li - &model.SiteTask{}, // 站点拨测任务 // Logan.Li - &model.SiteResult{}, // 拨测结果 // Logan.Li - &model.SiteAlert{}, // 拨测告警 // Logan.Li - &model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li + &model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li + &model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li + &model.RDPSession{}, // RDP 图形会话审计 // Logan.Li + &model.SessionCommand{}, // 终端会话命令审计 // Logan.Li + &model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li + &model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li + &model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li + &model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li + &model.MonitorMetric{}, // 监控指标定义 // Logan.Li + &model.MetricValue{}, // 指标当前值 // Logan.Li + &model.MetricHistory{}, // 指标历史时序 // Logan.Li + &model.HostGroup{}, // 主机组 // Logan.Li + &model.SNMPDevice{}, // SNMP设备 // Logan.Li + &model.SNMPTemplate{}, // SNMP模板 // Logan.Li + &model.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li + &model.SNMPMetric{}, // SNMP采集指标 // Logan.Li + &model.SNMPInterface{}, // SNMP接口信息 // Logan.Li + &model.SiteTask{}, // 站点拨测任务 // Logan.Li + &model.SiteResult{}, // 拨测结果 // Logan.Li + &model.SiteAlert{}, // 拨测告警 // Logan.Li + &model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li &model.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li &model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li &model.LogDataSource{}, // Loki / ES 日志数据源 // Logan.Li - &model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li - &model.PipelineRepo{}, // Git 仓库认证 // Logan.Li - &model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li - &model.DeployEnvironment{}, // 发布环境分组 // Logan.Li - &model.DeployPipelineMember{}, // 流水线成员 // Logan.Li - &model.DeployUserGroup{}, // 用户组 // Logan.Li + &model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li + &model.PipelineRepo{}, // Git 仓库认证 // Logan.Li + &model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li + &model.DeployEnvironment{}, // 发布环境分组 // Logan.Li + &model.DeployPipelineMember{}, // 流水线成员 // Logan.Li + &model.DeployUserGroup{}, // 用户组 // Logan.Li &model.DeployUserGroupMember{}, // 用户组成员 // Logan.Li - &model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li - &model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li - &model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li - &model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li - &model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li - &model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li + &model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li + &model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li + &model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li + &model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li + &model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li + &model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li &model.AlertCondition{}, // 告警条件 // Logan.Li &model.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li &model.AlertUpgrade{}, // 告警升级策略 // Logan.Li &model.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li &model.AlertSilence{}, // 告警静默 // Logan.Li - &model.HealthProbe{}, // 健康探针配置 // Logan.Li + &model.HealthProbe{}, // 健康探针配置 // Logan.Li &model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li - &model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li - &model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li - &model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li - &model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li - &model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li + &model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li + &model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li + &model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li + &model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li + &model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li } var lastErr error @@ -238,4 +239,26 @@ func applySchemaPatches(db *gorm.DB) { 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 + } } diff --git a/backend/pkg/rdp/README.md b/backend/pkg/rdp/README.md new file mode 100644 index 0000000..fe515d4 --- /dev/null +++ b/backend/pkg/rdp/README.md @@ -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. 新增测试优先覆盖底层域目录,再用根包探针做资产联调。 diff --git a/backend/pkg/rdp/bitmap_decode.go b/backend/pkg/rdp/bitmap_decode.go new file mode 100644 index 0000000..4f99ebf --- /dev/null +++ b/backend/pkg/rdp/bitmap_decode.go @@ -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 +} diff --git a/backend/pkg/rdp/client.go b/backend/pkg/rdp/client.go new file mode 100644 index 0000000..10e7a34 --- /dev/null +++ b/backend/pkg/rdp/client.go @@ -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 +} diff --git a/backend/pkg/rdp/credssp/asn1.go b/backend/pkg/rdp/credssp/asn1.go new file mode 100644 index 0000000..d02ebe1 --- /dev/null +++ b/backend/pkg/rdp/credssp/asn1.go @@ -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}) +} diff --git a/backend/pkg/rdp/credssp/credssp.go b/backend/pkg/rdp/credssp/credssp.go new file mode 100644 index 0000000..5ba6c39 --- /dev/null +++ b/backend/pkg/rdp/credssp/credssp.go @@ -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 +} diff --git a/backend/pkg/rdp/credssp/credssp_test.go b/backend/pkg/rdp/credssp/credssp_test.go new file mode 100644 index 0000000..142dec4 --- /dev/null +++ b/backend/pkg/rdp/credssp/credssp_test.go @@ -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") + } +} diff --git a/backend/pkg/rdp/credssp/ntlm.go b/backend/pkg/rdp/credssp/ntlm.go new file mode 100644 index 0000000..ffc4d37 --- /dev/null +++ b/backend/pkg/rdp/credssp/ntlm.go @@ -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) +} diff --git a/backend/pkg/rdp/credssp/pubkey_test.go b/backend/pkg/rdp/credssp/pubkey_test.go new file mode 100644 index 0000000..ce6fb89 --- /dev/null +++ b/backend/pkg/rdp/credssp/pubkey_test.go @@ -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)) + } +} \ No newline at end of file diff --git a/backend/pkg/rdp/display/framebuffer.go b/backend/pkg/rdp/display/framebuffer.go new file mode 100644 index 0000000..91ff09c --- /dev/null +++ b/backend/pkg/rdp/display/framebuffer.go @@ -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 +} diff --git a/backend/pkg/rdp/domain/types.go b/backend/pkg/rdp/domain/types.go new file mode 100644 index 0000000..df19d44 --- /dev/null +++ b/backend/pkg/rdp/domain/types.go @@ -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) +} diff --git a/backend/pkg/rdp/engine.go b/backend/pkg/rdp/engine.go new file mode 100644 index 0000000..2d1d352 --- /dev/null +++ b/backend/pkg/rdp/engine.go @@ -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) +} diff --git a/backend/pkg/rdp/fastpath_recv.go b/backend/pkg/rdp/fastpath_recv.go new file mode 100644 index 0000000..f2d1e1b --- /dev/null +++ b/backend/pkg/rdp/fastpath_recv.go @@ -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 +} diff --git a/backend/pkg/rdp/input_fastpath.go b/backend/pkg/rdp/input_fastpath.go new file mode 100644 index 0000000..195f9a2 --- /dev/null +++ b/backend/pkg/rdp/input_fastpath.go @@ -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) +} diff --git a/backend/pkg/rdp/keymap.go b/backend/pkg/rdp/keymap.go new file mode 100644 index 0000000..49e60c0 --- /dev/null +++ b/backend/pkg/rdp/keymap.go @@ -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, +} diff --git a/backend/pkg/rdp/mcs_probe.go b/backend/pkg/rdp/mcs_probe.go new file mode 100644 index 0000000..77690ae --- /dev/null +++ b/backend/pkg/rdp/mcs_probe.go @@ -0,0 +1,1664 @@ +package rdp + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "time" + "unicode/utf16" + + "ezops/pkg/rdp/credssp" + "ezops/pkg/rdp/transport" +) + +const ( + mcsConnectInitial = 0x65 + mcsConnectResponse = 0x66 + + mcsErectDomainRequest = 1 + mcsAttachUserRequest = 10 + mcsAttachUserConfirm = 11 + mcsChannelJoinRequest = 14 + mcsChannelJoinConfirm = 15 + mcsSendDataRequest = 25 + mcsSendDataIndication = 26 + mcsGlobalChannelID = 1003 + mcsUserChannelIDOffset = 1001 + + gccCSCore = 0xC001 + gccCSSecurity = 0xC002 + gccCSNet = 0xC003 + gccSCCore = 0x0C01 + gccSCSecurity = 0x0C02 + gccSCNet = 0x0C03 + + secFlagInfoPkt = 0x0040 + secFlagLicensePkt = 0x0080 + + licenseErrorAlert = 0xff + licenseStatusValid = 0x00000007 + licenseStateNoTransit = 0x00000002 + pduTypeDemandActive = 0x0011 + pduTypeConfirmActive = 0x0013 + pduTypeDeactivateAll = 0x0016 + pduTypeData = 0x0017 + pduTypeServerRedirect = 0x001a + pduType2Synchronize = 0x1f + pduType2Control = 0x14 + pduType2FontList = 0x27 + pduType2FontMap = 0x28 + pduType2RefreshRect = 0x21 + pduType2SetErrorInfo = 0x2f + pduType2SuppressOutput = 0x23 + pduType2SaveSessionInfo = 0x26 + pduType2FrameAcknowledge = 0x38 + ctrlActionGrantedControl = 0x0002 +) + +var errFinalizeActive = errors.New("RDP 会话已进入图形更新流") + +// MCSProbeInfo 记录 NLA 后 MCS/GCC 连接期响应,用于确认图形会话层入口是否跑通 // Logan.Li +type MCSProbeInfo struct { + HandshakeInfo + UserDataBytes int + ServerCoreVersion uint32 + ServerChannelID uint16 + ServerChannelCount uint16 + ServerChannelIDs []uint16 + ServerEncryption uint32 + ServerEncryptionLvl uint32 + UserChannelID uint16 + MessageChannelID uint16 + JoinedChannels []uint16 + FirstDataChannelID uint16 + FirstSecurityFlags uint16 + FirstPayloadBytes int + LicenseMessageType uint8 + LicenseErrorCode uint32 + LicenseState uint32 + DemandPDUType uint16 + DemandSharedID uint32 + DemandCapabilities uint16 + DemandSource string + PostConfirmTypes []uint8 +} + +// ProbeMCSConnect 复用同一条 CredSSP TLS 连接发送 MCS Connect Initial 并解析 Connect Response // Logan.Li +func ProbeMCSConnect(ctx context.Context, cfg Config) (MCSProbeInfo, error) { + port := cfg.Port + if port == 0 { + port = 3389 + } + address := fmt.Sprintf("%s:%d", cfg.Host, port) + start := time.Now() + rdpDebugf(cfg, "probe start address=%s mode=%q", address, cfg.MCSMode) + 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 { + rdpDebugf(cfg, "strategy start version=%d pubkey=%s cbt=%v mic=%v", opts.Version, opts.PubKeyStyle, opts.UseCBT, opts.ForceMIC) + info, err := probeMCSConnectOnce(ctx, cfg, address, opts) + if err == nil { + info.Duration = time.Since(start) + rdpDebugf(cfg, "probe ok duration=%s joined=%v post=%v", info.Duration.Round(time.Millisecond), info.JoinedChannels, info.PostConfirmTypes) + return info, nil + } + rdpDebugf(cfg, "strategy failed version=%d pubkey=%s err=%v", opts.Version, opts.PubKeyStyle, err) + lastErr = err + } + if lastErr == nil { + lastErr = fmt.Errorf("MCS Connect Initial 失败") + } + return MCSProbeInfo{HandshakeInfo: HandshakeInfo{Address: address, Duration: time.Since(start)}}, lastErr +} + +func probeMCSConnectOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (MCSProbeInfo, error) { + start := time.Now() + tlsConn, result, err := dialCredSSPOnce(ctx, cfg, address, opts) + if err != nil { + return MCSProbeInfo{}, err + } + defer tlsConn.Close() + rdpDebugf(cfg, "credssp ok protocol=%s elapsed=%s", result.ProtocolName(), time.Since(start).Round(time.Millisecond)) + _ = tlsConn.SetDeadline(time.Now().Add(30 * time.Second)) + rdpDebugf(cfg, "send MCS ConnectInitial") + if err := transport.WriteX224Data(tlsConn, buildMCSConnectInitial(cfg)); err != nil { + return MCSProbeInfo{}, err + } + payload, err := transport.ReadX224Data(tlsConn) + if err != nil { + return MCSProbeInfo{}, err + } + if cfg.Debug { + debugGCCServerBlocks(cfg, payload) + } + mcsInfo, err := parseMCSConnectResponse(payload) + if err != nil { + return MCSProbeInfo{}, err + } + rdpDebugf(cfg, "connect response userData=%d channel=%d channels=%v enc=%d/%d", mcsInfo.UserDataBytes, mcsInfo.ServerChannelID, mcsInfo.ServerChannelIDs, mcsInfo.ServerEncryption, mcsInfo.ServerEncryptionLvl) + userID, err := attachMCSUser(tlsConn) + if err != nil { + return MCSProbeInfo{}, err + } + mcsInfo.UserChannelID = userID + rdpDebugf(cfg, "attach user ok userID=%d", userID) + joinIDs := append([]uint16{mcsGlobalChannelID, userID}, mcsInfo.ServerChannelIDs...) + if mcsInfo.MessageChannelID != 0 { + joinIDs = append(joinIDs, mcsInfo.MessageChannelID) + } + for _, channelID := range joinIDs { + joined, err := joinMCSChannel(tlsConn, userID, channelID) + if err != nil { + return MCSProbeInfo{}, err + } + mcsInfo.JoinedChannels = append(mcsInfo.JoinedChannels, joined) + rdpDebugf(cfg, "join channel ok channel=%d", joined) + } + first, demand, err := sendClientInfoAndReadDemand(tlsConn, cfg, userID) + if err != nil { + return MCSProbeInfo{}, err + } + mcsInfo.FirstDataChannelID = first.channelID + mcsInfo.FirstSecurityFlags = first.securityFlags + mcsInfo.FirstPayloadBytes = len(first.payload) + mcsInfo.LicenseMessageType = first.licenseMsgType + mcsInfo.LicenseErrorCode = first.licenseErrorCode + mcsInfo.LicenseState = first.licenseState + mcsInfo.DemandPDUType = demand.pduType + mcsInfo.DemandSharedID = demand.sharedID + mcsInfo.DemandCapabilities = demand.capabilityCount + mcsInfo.DemandSource = string(demand.sourceDescriptor) + rdpDebugf(cfg, "demand active shared=0x%08x caps=%d source=%q firstSecurity=0x%04x firstPayload=%d", demand.sharedID, demand.capabilityCount, string(demand.sourceDescriptor), first.securityFlags, len(first.payload)) + postTypes, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand) + if err != nil { + return MCSProbeInfo{}, fmt.Errorf("Client Finalize 失败: shared=0x%08x caps=%d source=%q: %w", demand.sharedID, demand.capabilityCount, string(demand.sourceDescriptor), err) + } + mcsInfo.PostConfirmTypes = postTypes + state := tlsConn.ConnectionState() + certSubject := "" + if len(state.PeerCertificates) > 0 { + certSubject = state.PeerCertificates[0].Subject.String() + } + mcsInfo.HandshakeInfo = HandshakeInfo{ + Address: address, + SelectedProtocol: result.ProtocolName(), + TLSCipher: transport.TLSCipherSuiteName(state.CipherSuite), + CertSubject: certSubject, + } + return mcsInfo, nil +} + +func debugGCCServerBlocks(cfg Config, mcsPayload []byte) { + info, err := parseMCSConnectResponse(mcsPayload) + if err != nil { + rdpDebugf(cfg, "debug gcc parse failed err=%v", err) + return + } + rdpDebugf(cfg, "debug gcc summary userData=%d core=0x%08x scNet=%d msg=%d", info.UserDataBytes, info.ServerCoreVersion, info.ServerChannelID, info.MessageChannelID) +} + +type mcsDataIndication struct { + channelID uint16 + securityFlags uint16 + payload []byte + licenseMsgType uint8 + licenseErrorCode uint32 + licenseState uint32 +} + +type demandActiveProbe struct { + pduType uint16 + sharedID uint32 + capabilityCount uint16 + sourceDescriptor []byte +} + +func sendClientInfoAndReadDemand(rw io.ReadWriter, cfg Config, userID uint16) (mcsDataIndication, demandActiveProbe, error) { + infoPDU := buildClientInfoPDU(cfg) + rdpDebugf(cfg, "send ClientInfo userID=%d bytes=%d", userID, len(infoPDU)) + if err := transport.WriteX224Data(rw, buildMCSSendDataRequest(userID, mcsGlobalChannelID, infoPDU)); err != nil { + return mcsDataIndication{}, demandActiveProbe{}, err + } + payload, err := transport.ReadX224Data(rw) + if err != nil { + return mcsDataIndication{}, demandActiveProbe{}, err + } + first, err := parseMCSDataIndication(payload) + if err != nil { + return mcsDataIndication{}, demandActiveProbe{}, err + } + if first.securityFlags&secFlagLicensePkt != 0 { + rdpDebugf(cfg, "license packet security=0x%04x payload=%d", first.securityFlags, len(first.payload)) + if err := parseLicensePacket(&first); err != nil { + return first, demandActiveProbe{}, err + } + payload, err = transport.ReadX224Data(rw) + if err != nil { + return first, demandActiveProbe{}, err + } + } + next, err := parseMCSDataIndication(payload) + if err != nil { + return first, demandActiveProbe{}, err + } + demand, err := parseDemandActiveProbe(next.payload) + if err != nil { + return first, demandActiveProbe{}, err + } + return first, demand, nil +} + +func sendConfirmActiveAndFinalize(rw io.ReadWriter, cfg Config, userID uint16, demand demandActiveProbe) ([]uint8, error) { + rdpDebugf(cfg, "finalize start userID=%d shared=0x%08x", userID, demand.sharedID) + pdus := []struct { + name string + data []byte + }{ + {"ConfirmActive", buildConfirmActivePDU(cfg, userID, demand)}, + {"Synchronize", buildDataPDU(userID, demand.sharedID, pduType2Synchronize, buildSynchronizeData(userID))}, + {"ControlCooperate", buildDataPDU(userID, demand.sharedID, pduType2Control, buildControlData(0x0004))}, + {"ControlRequest", buildDataPDU(userID, demand.sharedID, pduType2Control, buildControlData(0x0001))}, + {"FontList", buildDataPDU(userID, demand.sharedID, pduType2FontList, buildFontListData())}, + } + var types []uint8 + for _, pdu := range pdus { + rdpDebugf(cfg, "finalize send %s bytes=%d", pdu.name, len(pdu.data)) + if err := transport.WriteX224Data(rw, buildMCSSendDataRequest(userID, mcsGlobalChannelID, pdu.data)); err != nil { + return nil, fmt.Errorf("发送 %s 失败: %w", pdu.name, err) + } + if isRDPStepMode(cfg) { + if stepTypes, err := readFinalizeStep(rw, pdu.name, 500*time.Millisecond); err != nil { + return append(types, stepTypes...), err + } else if len(stepTypes) > 0 { + types = append(types, stepTypes...) + } + } + } + readTypes, err := waitFinalizeResponses(rw, cfg, 5*time.Second) + if err != nil { + return types, err + } + types = append(types, readTypes...) + return types, nil +} + +func isRDPStepMode(cfg Config) bool { + return cfg.MCSMode == "step" || cfg.MCSMode == "step-legacy" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "step-minimal" || cfg.MCSMode == "step-plain-info" || cfg.MCSMode == "step-grdp-comment" || cfg.MCSMode == "step-grdp-modern" +} + +func readFinalizeStep(rw io.ReadWriter, name string, timeout time.Duration) ([]uint8, error) { + conn, ok := rw.(interface{ SetReadDeadline(time.Time) error }) + if !ok { + return nil, nil + } + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + defer conn.SetReadDeadline(time.Time{}) + payload, err := transport.ReadX224Data(rw) + if err != nil { + return nil, nil + } + data, err := parseMCSDataIndication(payload) + if err != nil { + return nil, fmt.Errorf("%s 后响应解析失败: %w", name, err) + } + got, err := parseShareDataPDUInfo(data.payload) + if err != nil { + return nil, fmt.Errorf("%s 后 Share Data 解析失败: %w", name, err) + } + if got.type2 == pduType2SetErrorInfo { + return []uint8{got.type2}, fmt.Errorf("%s 后服务端返回 ErrorInfo: code=0x%08x(%s) raw=%s", name, got.errorInfo, rdpErrorInfoName(got.errorInfo), got.rawPrefix) + } + return []uint8{got.type2}, nil +} + +func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration) ([]uint8, error) { + if conn, ok := rw.(interface{ SetReadDeadline(time.Time) error }); ok { + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + defer conn.SetReadDeadline(time.Time{}) + } + expected := []finalizeExpectation{ + {type2: pduType2Synchronize}, + {type2: pduType2Control, action: 0x0004}, + {type2: pduType2Control, action: ctrlActionGrantedControl}, + {type2: pduType2FontMap}, + } + var types []uint8 + for _, want := range expected { + for { + payload, err := readNextSlowPathPayload(rw, cfg, timeout) + if err != nil { + if errors.Is(err, errFinalizeActive) || len(types) > 0 { + rdpDebugf(cfg, "finalize accept active session types=%v err=%v", types, err) + return types, nil + } + return types, fmt.Errorf("读取 Client Finalize 响应失败: %w", err) + } + data, err := parseMCSDataIndication(payload) + if err != nil { + return types, err + } + got, err := parseShareDataPDUInfo(data.payload) + if err != nil { + return types, err + } + types = append(types, got.type2) + rdpDebugf(cfg, "finalize recv type2=0x%02x action=0x%04x raw=%s", got.type2, got.controlAction, got.rawPrefix) + if got.type2 == pduType2SetErrorInfo { + return types, fmt.Errorf("服务端返回 ErrorInfo: code=0x%08x(%s) seen=%v raw=%s", got.errorInfo, rdpErrorInfoName(got.errorInfo), types, got.rawPrefix) + } + if got.type2 == pduType2SaveSessionInfo { + continue + } + if got.type2 != want.type2 { + continue + } + if want.type2 == pduType2Control && got.controlAction != want.action { + continue + } + break + } + } + return types, nil +} + +func readNextSlowPathPayload(r io.Reader, cfg Config, timeout time.Duration) ([]byte, error) { + deadline := time.Now().Add(timeout) + seenFastPath := false + for { + pkt, err := ReadWirePacket(r) + if err != nil { + if seenFastPath { + return nil, errFinalizeActive + } + return nil, err + } + if !pkt.FastPath { + return pkt.Payload, nil + } + seenFastPath = true + rdpDebugf(cfg, "finalize skip fastpath sec=0x%02x payload=%d", pkt.SecFlag, len(pkt.Payload)) + if time.Now().After(deadline) { + return nil, errFinalizeActive + } + } +} + +type finalizeExpectation struct { + type2 uint8 + action uint16 +} + +func buildConfirmActivePDU(cfg Config, userID uint16, demand demandActiveProbe) []byte { + source := demand.sourceDescriptor + if len(source) == 0 { + source = []byte("rdpy") + } + body := bytes.Buffer{} + writeLE32(&body, demand.sharedID) + writeLE16(&body, 0x03ea) + writeLE16(&body, uint16(len(source))) + caps, capCount := buildConfirmCapabilities(cfg) + rdpDebugf(cfg, "build ConfirmActive sourceLen=%d caps=%d capsBytes=%d", len(source), capCount, len(caps)) + debugCapabilityList(cfg, caps) + writeLE16(&body, uint16(len(caps)+4)) + body.Write(source) + writeLE16(&body, capCount) + writeLE16(&body, 0) + body.Write(caps) + pdu := buildShareControlPDU(userID, pduTypeConfirmActive, body.Bytes()) + prefixLen := len(pdu) + if prefixLen > 96 { + prefixLen = 96 + } + rdpDebugf(cfg, "ConfirmActive pduLen=%d prefix=%s", len(pdu), hex.EncodeToString(pdu[:prefixLen])) + return pdu +} + +func rdpDebugf(cfg Config, format string, args ...interface{}) { + if !cfg.Debug { + return + } + _, _ = fmt.Fprintf(os.Stderr, "[rdp] "+format+"\n", args...) +} + +func rdpErrorInfoName(code uint32) string { + switch code { + case 0x000010EA: + return "ERRINFO_BAD_CAPABILITIES" + case 0x000010E9: + return "ERRINFO_NO_CURSOR_CACHE" + case 0x000010E8: + return "ERRINFO_CAPABILITY_SET_TOO_LARGE" + case 0x000010E7: + return "ERRINFO_CAPABILITY_SET_TOO_SMALL" + case 0x000010D4: + return "ERRINFO_CONFIRM_ACTIVE_HAS_WRONG_SHAREID" + case 0x000010D5: + return "ERRINFO_CONFIRM_ACTIVE_HAS_WRONG_ORIGINATOR" + default: + return "UNKNOWN" + } +} + +func debugCapabilityList(cfg Config, caps []byte) { + if !cfg.Debug { + return + } + offset := 0 + index := 0 + for offset+4 <= len(caps) { + capType := binary.LittleEndian.Uint16(caps[offset : offset+2]) + capLen := int(binary.LittleEndian.Uint16(caps[offset+2 : offset+4])) + if capLen < 4 || offset+capLen > len(caps) { + rdpDebugf(cfg, "cap[%02d] invalid offset=%d type=0x%04x len=%d total=%d", index, offset, capType, capLen, len(caps)) + return + } + body := caps[offset+4 : offset+capLen] + prefixLen := len(body) + if prefixLen > 24 { + prefixLen = 24 + } + rdpDebugf(cfg, "cap[%02d] type=0x%04x len=%d body=%s", index, capType, capLen, hex.EncodeToString(body[:prefixLen])) + offset += capLen + index++ + } + if offset != len(caps) { + rdpDebugf(cfg, "cap trailing bytes=%d offset=%d total=%d", len(caps)-offset, offset, len(caps)) + } +} + +func buildConfirmCapabilities(cfg Config) ([]byte, uint16) { + width := cfg.Width + if width <= 0 { + width = 1280 + } + height := cfg.Height + if height <= 0 { + height = 800 + } + out := bytes.Buffer{} + count := uint16(0) + addCapability := func(capType uint16, body []byte) { + writeCapability(&out, capType, body) + count++ + } + if cfg.MCSMode == "grdp-comment" || cfg.MCSMode == "step-grdp-comment" { + return buildGRDPCommentCapabilities(cfg, width, height) + } + addCapability(0x0001, buildGeneralCapability()) + bitmap := bytes.Buffer{} + preferredBPP := uint16(24) + if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" { + preferredBPP = 32 + } + writeLE16(&bitmap, preferredBPP) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, uint16(width)) + writeLE16(&bitmap, uint16(height)) + writeLE16(&bitmap, 0) + writeLE16(&bitmap, 1) + if cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" { + writeLE16(&bitmap, 0) + } else { + writeLE16(&bitmap, 1) + } + writeLE16(&bitmap, 0) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, 0) + addCapability(0x0002, bitmap.Bytes()) + if cfg.MCSMode == "basic" { + addCapability(0x000d, buildInputCapability()) + return out.Bytes(), count + } + if cfg.MCSMode == "legacy" || cfg.MCSMode == "step-legacy" { + addCapability(0x0003, mustHex("00000000000000000000000000000000010014000000010000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008403000000000000000000")) + addCapability(0x0005, mustHex("0000000002000200")) + addCapability(0x0007, mustHex("0000000000000000")) + addCapability(0x0008, buildPointerCapability(128)) + addCapability(0x0009, mustHex("00000000")) + addCapability(0x000a, mustHex("06000000")) + addCapability(0x000c, mustHex("01000000")) + addCapability(0x000d, buildInputCapability()) + addCapability(0x000e, mustHex("01000000")) + addCapability(0x000f, mustHex("01000000")) + addCapability(0x0010, make([]byte, 48)) + addCapability(0x0014, mustHex("0000000040060000")) + return out.Bytes(), count + } + if cfg.MCSMode == "minimal" || cfg.MCSMode == "step-minimal" { + addCapability(0x0003, mustHex("00000000000000000000000000000000010014000000010000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008403000000000000000000")) + addCapability(0x0008, mustHex("00001400")) + addCapability(0x000d, buildInputCapability()) + addCapability(0x000e, mustHex("01000000")) + return out.Bytes(), count + } + if cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" { + addCapability(0x0003, buildGRDPOrderCapability()) + addCapability(0x0005, mustHex("0000000002000200")) + addCapability(0x0007, mustHex("0000000000000000")) + addCapability(0x0008, buildPointerCapability(20)) + addCapability(0x0009, mustHex("00000000")) + addCapability(0x000a, mustHex("06000000")) + addCapability(0x000c, mustHex("01000000")) + addCapability(0x000d, buildInputCapability()) + addCapability(0x000e, mustHex("01000000")) + addCapability(0x000f, mustHex("01000000")) + addCapability(0x0010, buildGlyphNoneCapability()) + addCapability(0x001d, buildBitmapCodecsCapability()) + addCapability(0x0013, buildBitmapCacheRev2CapabilityWithPersist(2)) + addCapability(0x0014, mustHex("0000000040060000")) + addCapability(0x001a, mustHex("00003f00")) + addCapability(0x0017, mustHex("ff000000")) + addCapability(0x001b, mustHex("0100")) + addCapability(0x0019, mustHex("0100")) + addCapability(0x001c, mustHex("5200000000000000")) + addCapability(0x001e, mustHex("02000000")) + return out.Bytes(), count + } + addCapability(0x0003, mustHex("00000000000000000000000000000000010014000000010000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008403000000000000000000")) + addCapability(0x0008, buildPointerCapability(128)) + addCapability(0x000c, mustHex("00000000")) + addCapability(0x000d, buildInputCapability()) + addCapability(0x000f, mustHex("00000000")) + addCapability(0x0010, make([]byte, 48)) + addCapability(0x0013, buildBitmapCacheRev2Capability()) + addCapability(0x0011, mustHex("0000000000000000")) + addCapability(0x0014, mustHex("0000000000000000")) + addCapability(0x001a, mustHex("00000000")) + return out.Bytes(), count +} + +func buildGRDPCommentCapabilities(cfg Config, width int, height int) ([]byte, uint16) { + out := bytes.Buffer{} + count := uint16(0) + addCapability := func(capType uint16, body []byte) { + writeCapability(&out, capType, body) + count++ + } + addCapability(0x0001, buildGeneralCapability()) + bitmap := bytes.Buffer{} + writeLE16(&bitmap, 24) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, uint16(width)) + writeLE16(&bitmap, uint16(height)) + writeLE16(&bitmap, 0) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, 0) + writeLE16(&bitmap, 1) + writeLE16(&bitmap, 0) + addCapability(0x0002, bitmap.Bytes()) + addCapability(0x0003, mustHex("000000000000000000000000000000000000000000000000010014000000010000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000008403000000000000000000")) + addCapability(0x0004, make([]byte, 36)) + addCapability(0x0008, mustHex("00001400")) + addCapability(0x000c, mustHex("00000000")) + addCapability(0x000d, buildInputCapability()) + addCapability(0x000f, mustHex("00000000")) + addCapability(0x0010, buildGlyphNoneCapability()) + addCapability(0x0011, mustHex("0000000000000000")) + addCapability(0x0014, mustHex("0000000000000000")) + addCapability(0x001a, mustHex("00000000")) + rdpDebugf(cfg, "grdp-comment caps=%d bytes=%d", count, out.Len()) + return out.Bytes(), count +} + +func buildGRDPOrderCapability() []byte { + out := bytes.Buffer{} + out.Write(make([]byte, 16)) + writeLE32(&out, 0) + writeLE16(&out, 1) + writeLE16(&out, 20) + writeLE16(&out, 0) + writeLE16(&out, 1) + writeLE16(&out, 0) + writeLE16(&out, 0x00aa) + orderSupport := make([]byte, 32) + // native 暂不处理 Orders,能力集必须全关,避免服务端只发订单导致桌面黑屏 // Logan.Li + out.Write(orderSupport) + writeLE16(&out, 0) + writeLE16(&out, 0x0004) + writeLE32(&out, 0) + writeLE32(&out, 480*480) + writeLE16(&out, 0) + writeLE16(&out, 0) + writeLE16(&out, 0x04e4) + writeLE16(&out, 0) + return out.Bytes() +} + +func buildGeneralCapability() []byte { + out := bytes.Buffer{} + writeLE16(&out, 0x0001) + writeLE16(&out, 0x0003) + writeLE16(&out, 0x0200) + writeLE16(&out, 0) + writeLE16(&out, 0x0002) + writeLE16(&out, 0x041d) + writeLE16(&out, 0) + writeLE16(&out, 0) + writeLE16(&out, 0) + out.WriteByte(1) + out.WriteByte(1) + return out.Bytes() +} + +func buildPointerCapability(cacheSize uint16) []byte { + out := bytes.Buffer{} + writeLE16(&out, 1) + writeLE16(&out, cacheSize) + writeLE16(&out, cacheSize) + return out.Bytes() +} + +func buildBitmapCacheRev2Capability() []byte { + return buildBitmapCacheRev2CapabilityWithPersist(0) +} + +func buildBitmapCacheRev2CapabilityWithPersist(persist uint16) []byte { + out := bytes.Buffer{} + writeLE16(&out, persist) + out.WriteByte(0) + out.WriteByte(5) + for _, cells := range []uint32{0x258, 0x258, 0x800, 0x1000, 0x800} { + writeLE32(&out, cells) + } + out.Write(make([]byte, 12)) + return out.Bytes() +} + +func buildGlyphNoneCapability() []byte { + out := bytes.Buffer{} + out.Write(make([]byte, 40)) + writeLE32(&out, 0) + writeLE16(&out, 0) + writeLE16(&out, 0) + return out.Bytes() +} + +func buildBitmapCodecsCapability() []byte { + out := bytes.Buffer{} + out.WriteByte(2) + out.Write(mustHex("b91b8dca0f004f15589fae2d1a87e2d6")) + out.WriteByte(1) + writeLE16(&out, 3) + out.Write([]byte{1, 1, 3}) + out.Write(mustHex("122f777672bd6344afb3b73c9c6f7886")) + out.WriteByte(3) + rfx := buildRemoteFXProperties() + writeLE16(&out, uint16(len(rfx))) + out.Write(rfx) + return out.Bytes() +} + +func buildRemoteFXProperties() []byte { + return mustHex("29000000000000001d000000c0cb080000000100c1cb1500000001c0cf010008000001400001010104") +} + +func buildInputCapability() []byte { + out := bytes.Buffer{} + writeLE16(&out, 0x003d) + writeLE16(&out, 0) + writeLE32(&out, 0x00000409) + writeLE32(&out, 4) + writeLE32(&out, 0) + writeLE32(&out, 12) + out.Write(make([]byte, 64)) + return out.Bytes() +} + +func writeCapability(w io.Writer, capType uint16, body []byte) { + writeLE16(w, capType) + writeLE16(w, uint16(len(body)+4)) + _, _ = w.Write(body) +} + +func buildShareControlPDU(userID uint16, pduType uint16, body []byte) []byte { + out := bytes.Buffer{} + writeLE16(&out, uint16(len(body)+6)) + writeLE16(&out, pduType) + writeLE16(&out, userID) + out.Write(body) + return out.Bytes() +} + +func buildDataPDU(userID uint16, sharedID uint32, type2 uint8, body []byte) []byte { + data := bytes.Buffer{} + writeLE32(&data, sharedID) + data.WriteByte(0) + data.WriteByte(1) + writeLE16(&data, uint16(len(body)+4)) + data.WriteByte(type2) + data.WriteByte(0) + writeLE16(&data, 0) + data.Write(body) + return buildShareControlPDU(userID, pduTypeData, data.Bytes()) +} + +func buildSynchronizeData(userID uint16) []byte { + out := bytes.Buffer{} + writeLE16(&out, 1) + writeLE16(&out, userID) + return out.Bytes() +} + +func buildControlData(action uint16) []byte { + out := bytes.Buffer{} + writeLE16(&out, action) + writeLE16(&out, 0) + writeLE32(&out, 0) + return out.Bytes() +} + +func buildFontListData() []byte { + out := bytes.Buffer{} + writeLE16(&out, 0) + writeLE16(&out, 0) + writeLE16(&out, 0x0003) + writeLE16(&out, 0x0032) + return out.Bytes() +} + +func attachMCSUser(rw io.ReadWriter) (uint16, error) { + if err := transport.WriteX224Data(rw, buildMCSErectDomainRequest()); err != nil { + return 0, err + } + if err := transport.WriteX224Data(rw, buildMCSAttachUserRequest()); err != nil { + return 0, err + } + payload, err := transport.ReadX224Data(rw) + if err != nil { + return 0, err + } + return parseMCSAttachUserConfirm(payload) +} + +func joinMCSChannel(rw io.ReadWriter, userID uint16, channelID uint16) (uint16, error) { + if err := transport.WriteX224Data(rw, buildMCSChannelJoinRequest(userID, channelID)); err != nil { + return 0, err + } + payload, err := transport.ReadX224Data(rw) + if err != nil { + return 0, err + } + return parseMCSChannelJoinConfirm(payload, userID, channelID) +} + +func buildMCSErectDomainRequest() []byte { + out := bytes.Buffer{} + writeMCSPDUHeader(&out, mcsErectDomainRequest) + writePERInteger(&out, 0) + writePERInteger(&out, 0) + return out.Bytes() +} + +func buildMCSAttachUserRequest() []byte { + out := bytes.Buffer{} + writeMCSPDUHeader(&out, mcsAttachUserRequest) + return out.Bytes() +} + +func buildMCSChannelJoinRequest(userID uint16, channelID uint16) []byte { + out := bytes.Buffer{} + writeMCSPDUHeader(&out, mcsChannelJoinRequest) + writeBE16Value(&out, userID-mcsUserChannelIDOffset) + writeBE16Value(&out, channelID) + return out.Bytes() +} + +func buildMCSSendDataRequest(userID uint16, channelID uint16, data []byte) []byte { + out := bytes.Buffer{} + writeMCSPDUHeader(&out, mcsSendDataRequest) + writeBE16Value(&out, userID-mcsUserChannelIDOffset) + writeBE16Value(&out, channelID) + out.WriteByte(0x70) + writePERLength(&out, len(data)) + out.Write(data) + return out.Bytes() +} + +func parseMCSAttachUserConfirm(data []byte) (uint16, error) { + r := bytes.NewReader(data) + pdu, err := readMCSPDUHeader(r) + if err != nil { + return 0, err + } + if pdu != mcsAttachUserConfirm { + return 0, fmt.Errorf("MCS AttachUserConfirm 类型错误: %d", pdu) + } + result, err := r.ReadByte() + if err != nil { + return 0, err + } + if result != 0 { + return 0, fmt.Errorf("MCS AttachUser 被拒绝: %d", result) + } + userID, err := readBE16Value(r) + if err != nil { + return 0, err + } + return userID + mcsUserChannelIDOffset, nil +} + +func parseMCSChannelJoinConfirm(data []byte, expectedUserID uint16, expectedChannelID uint16) (uint16, error) { + r := bytes.NewReader(data) + pdu, err := readMCSPDUHeader(r) + if err != nil { + return 0, err + } + if pdu != mcsChannelJoinConfirm { + return 0, fmt.Errorf("MCS ChannelJoinConfirm 类型错误: %d", pdu) + } + result, err := r.ReadByte() + if err != nil { + return 0, err + } + userID, err := readBE16Value(r) + if err != nil { + return 0, err + } + userID += mcsUserChannelIDOffset + channelID, err := readBE16Value(r) + if err != nil { + return 0, err + } + if userID != expectedUserID { + return 0, fmt.Errorf("MCS ChannelJoin 用户不匹配: got=%d want=%d", userID, expectedUserID) + } + if channelID != expectedChannelID { + return 0, fmt.Errorf("MCS ChannelJoin 通道不匹配: got=%d want=%d", channelID, expectedChannelID) + } + if result != 0 && (channelID == mcsGlobalChannelID || channelID == expectedUserID) { + return 0, fmt.Errorf("MCS 必需通道加入失败: channel=%d result=%d", channelID, result) + } + return channelID, nil +} + +func parseMCSDataIndication(data []byte) (mcsDataIndication, error) { + r := bytes.NewReader(data) + pdu, err := readMCSPDUHeader(r) + if err != nil { + return mcsDataIndication{}, err + } + if pdu != mcsSendDataIndication { + return mcsDataIndication{}, fmt.Errorf("MCS DataIndication 类型错误: %d", pdu) + } + if _, err := readBE16Value(r); err != nil { + return mcsDataIndication{}, err + } + channelID, err := readBE16Value(r) + if err != nil { + return mcsDataIndication{}, err + } + if _, err := r.ReadByte(); err != nil { + return mcsDataIndication{}, err + } + size, err := readPERLength(r) + if err != nil { + return mcsDataIndication{}, err + } + if int(size) > r.Len() { + return mcsDataIndication{}, io.ErrUnexpectedEOF + } + payload := make([]byte, int(size)) + _, _ = io.ReadFull(r, payload) + flags := uint16(0) + if len(payload) >= 4 { + flags = binary.LittleEndian.Uint16(payload[0:2]) + } + return mcsDataIndication{channelID: channelID, securityFlags: flags, payload: payload}, nil +} + +func parseLicensePacket(data *mcsDataIndication) error { + if len(data.payload) < 8 { + return fmt.Errorf("License PDU 太短: %d", len(data.payload)) + } + body := data.payload[4:] + data.licenseMsgType = body[0] + if len(body) >= 12 && data.licenseMsgType == licenseErrorAlert { + data.licenseErrorCode = binary.LittleEndian.Uint32(body[4:8]) + data.licenseState = binary.LittleEndian.Uint32(body[8:12]) + if data.licenseErrorCode == licenseStatusValid && data.licenseState == licenseStateNoTransit { + return nil + } + return fmt.Errorf("License ErrorAlert: code=0x%08x state=0x%08x", data.licenseErrorCode, data.licenseState) + } + return fmt.Errorf("暂未实现 License msgType=0x%02x 的响应", data.licenseMsgType) +} + +func parseDemandActiveProbe(payload []byte) (demandActiveProbe, error) { + r := bytes.NewReader(payload) + totalLength, err := readLE16(r) + if err != nil { + return demandActiveProbe{}, err + } + pduType, err := readLE16(r) + if err != nil { + return demandActiveProbe{}, err + } + if int(totalLength) > len(payload) { + return demandActiveProbe{}, fmt.Errorf("Share Control 长度超出数据: total=%d payload=%d", totalLength, len(payload)) + } + if pduType != pduTypeDemandActive { + return demandActiveProbe{pduType: pduType}, fmt.Errorf("等待 DemandActive,收到 PDU type=0x%04x", pduType) + } + if _, err := readLE16(r); err != nil { + return demandActiveProbe{}, err + } + sharedID, err := readLE32(r) + if err != nil { + return demandActiveProbe{}, err + } + sourceLen, err := readLE16(r) + if err != nil { + return demandActiveProbe{}, err + } + if _, err := readLE16(r); err != nil { + return demandActiveProbe{}, err + } + if int(sourceLen) > r.Len() { + return demandActiveProbe{}, io.ErrUnexpectedEOF + } + source := make([]byte, int(sourceLen)) + _, _ = io.ReadFull(r, source) + capabilityCount, err := readLE16(r) + if err != nil { + return demandActiveProbe{}, err + } + return demandActiveProbe{pduType: pduType, sharedID: sharedID, capabilityCount: capabilityCount, sourceDescriptor: source}, nil +} + +func parseShareDataPDUType(payload []byte) (uint8, error) { + info, err := parseShareDataPDUInfo(payload) + if err != nil { + return 0, err + } + return info.type2, nil +} + +type shareDataPDUInfo struct { + type2 uint8 + controlAction uint16 + errorInfo uint32 + rawPrefix string +} + +func parseShareDataPDUInfo(payload []byte) (shareDataPDUInfo, error) { + if len(payload) < 18 { + return shareDataPDUInfo{}, fmt.Errorf("Share Data PDU 太短: %d", len(payload)) + } + pduType := binary.LittleEndian.Uint16(payload[2:4]) + if pduType != pduTypeData { + return shareDataPDUInfo{}, fmt.Errorf("等待 Data PDU,收到 type=0x%04x", pduType) + } + prefixLen := len(payload) + if prefixLen > 32 { + prefixLen = 32 + } + info := shareDataPDUInfo{type2: payload[14], rawPrefix: hex.EncodeToString(payload[:prefixLen])} + if info.type2 == pduType2Control { + if len(payload) < 20 { + return shareDataPDUInfo{}, fmt.Errorf("Control Data PDU 太短: %d", len(payload)) + } + info.controlAction = binary.LittleEndian.Uint16(payload[18:20]) + } + if info.type2 == pduType2SetErrorInfo { + if len(payload) < 22 { + return shareDataPDUInfo{}, fmt.Errorf("ErrorInfo Data PDU 太短: %d", len(payload)) + } + info.errorInfo = binary.LittleEndian.Uint32(payload[18:22]) + } + return info, nil +} + +func mustHex(value string) []byte { + data, err := hex.DecodeString(value) + if err != nil { + panic(err) + } + return data +} + +func buildClientInfoPDU(cfg Config) []byte { + info := buildRDPInfo(cfg) + out := bytes.Buffer{} + writeLE16(&out, secFlagInfoPkt) + writeLE16(&out, 0) + out.Write(info) + return out.Bytes() +} + +func buildRDPInfo(cfg Config) []byte { + domain := utf16LENull(cfg.Domain) + user := utf16LENull(cfg.Username) + password := utf16LENull(cfg.Password) + alternateShell := []byte{0, 0} + workingDir := []byte{0, 0} + flags := uint32(0x00000001 | 0x00000002 | 0x00000008 | 0x00000010 | 0x00000020 | 0x00000040 | 0x00000100 | 0x00004000 | 0x00010000 | 0x00020000) + if cfg.MCSMode == "plain-info" || cfg.MCSMode == "step-plain-info" { + flags = 0x00000001 | 0x00000002 | 0x00000008 | 0x00004000 + } + + out := bytes.Buffer{} + writeLE32(&out, 0) + writeLE32(&out, flags) + writeLE16(&out, uint16(len(domain)-2)) + writeLE16(&out, uint16(len(user)-2)) + writeLE16(&out, uint16(len(password)-2)) + writeLE16(&out, uint16(len(alternateShell)-2)) + writeLE16(&out, uint16(len(workingDir)-2)) + out.Write(domain) + out.Write(user) + out.Write(password) + out.Write(alternateShell) + out.Write(workingDir) + out.Write(buildExtendedInfo()) + return out.Bytes() +} + +func buildExtendedInfo() []byte { + clientAddress := []byte{0, 0} + clientDir := []byte{0, 0} + out := bytes.Buffer{} + writeLE16(&out, 0x0002) + writeLE16(&out, uint16(len(clientAddress))) + out.Write(clientAddress) + writeLE16(&out, uint16(len(clientDir))) + out.Write(clientDir) + out.Write(make([]byte, 172)) + writeLE32(&out, 0) + writeLE32(&out, 0x00000001|0x00000002|0x00000004|0x00000080|0x00000100) + return out.Bytes() +} + +func buildMCSConnectInitial(cfg Config) []byte { + userData := bytes.Buffer{} + userData.Write(buildGCCClientCore(cfg)) + userData.Write(buildGCCClientNetwork()) + userData.Write(buildGCCClientSecurity()) + userData.Write(buildGCCClientMessageChannel()) + conference := buildGCCConferenceCreate(userData.Bytes()) + + body := bytes.Buffer{} + writeBEROctetString(bodyWriter(&body), []byte{0x01}) + writeBEROctetString(bodyWriter(&body), []byte{0x01}) + writeBERBoolean(bodyWriter(&body), true) + writeBERDomainParameters(bodyWriter(&body), 34, 2, 0, 0xffff) + writeBERDomainParameters(bodyWriter(&body), 1, 1, 1, 0x0420) + writeBERDomainParameters(bodyWriter(&body), 0xffff, 0xfc17, 0xffff, 0xffff) + writeBEROctetString(bodyWriter(&body), conference) + + out := bytes.Buffer{} + writeBERApplicationTag(bodyWriter(&out), mcsConnectInitial, body.Len()) + out.Write(body.Bytes()) + return out.Bytes() +} + +func buildGCCClientCore(cfg Config) []byte { + width := cfg.Width + if width <= 0 { + width = 1280 + } + height := cfg.Height + if height <= 0 { + height = 800 + } + highColorDepth := uint16(24) + supportedColorDepths := uint16(0x000f) + if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" { + highColorDepth = 32 + supportedColorDepths = 0x001f + } + body := bytes.Buffer{} + writeLE32(&body, 0x00080007) + writeLE16(&body, uint16(width)) + writeLE16(&body, uint16(height)) + writeLE16(&body, 0xCA01) + writeLE16(&body, 0xAA03) + writeLE32(&body, 0x00000409) + writeLE32(&body, 22621) + body.Write(fixedUTF16LE("AIOPS", 32)) + writeLE32(&body, 4) + writeLE32(&body, 0) + writeLE32(&body, 12) + body.Write(make([]byte, 64)) + writeLE16(&body, 0xCA01) + writeLE16(&body, 1) + writeLE32(&body, 0) + writeLE16(&body, highColorDepth) + writeLE16(&body, supportedColorDepths) + writeLE16(&body, 0x00a3) + body.Write(make([]byte, 64)) + body.WriteByte(0x06) + body.WriteByte(0) + writeLE32(&body, transport.ProtocolCredSSP) + return withGCCBlock(gccCSCore, body.Bytes()) +} + +func buildGCCClientNetwork() []byte { + body := bytes.Buffer{} + writeLE32(&body, 0) + return withGCCBlock(gccCSNet, body.Bytes()) +} + +func buildGCCClientSecurity() []byte { + body := bytes.Buffer{} + writeLE32(&body, 0x0000000b) + writeLE32(&body, 0) + return withGCCBlock(gccCSSecurity, body.Bytes()) +} + +func buildGCCClientMessageChannel() []byte { + body := bytes.Buffer{} + writeLE32(&body, 0) + return withGCCBlock(0xC006, body.Bytes()) +} + +func withGCCBlock(blockType uint16, body []byte) []byte { + out := bytes.Buffer{} + writeLE16(&out, blockType) + writeLE16(&out, uint16(len(body)+4)) + out.Write(body) + return out.Bytes() +} + +func buildGCCConferenceCreate(userData []byte) []byte { + out := bytes.Buffer{} + writePERChoice(&out, 0) + writePERObjectIdentifier(&out, []byte{0, 0, 20, 124, 0, 1}) + writePERLength(&out, len(userData)+14) + writePERChoice(&out, 0) + out.WriteByte(0x08) + writePERNumericString(&out, "1", 1) + out.WriteByte(0) + out.WriteByte(1) + writePERChoice(&out, 0xc0) + writePEROctetStream(&out, []byte("Duca"), 4) + writePEROctetStream(&out, userData, 0) + return out.Bytes() +} + +func parseMCSConnectResponse(data []byte) (MCSProbeInfo, error) { + r := bytes.NewReader(data) + if err := readBERApplicationTag(r, mcsConnectResponse); err != nil { + return MCSProbeInfo{}, err + } + if _, err := readBEREnumerated(r); err != nil { + return MCSProbeInfo{}, err + } + if _, err := readBERInteger(r); err != nil { + return MCSProbeInfo{}, err + } + if err := skipBERDomainParameters(r); err != nil { + return MCSProbeInfo{}, err + } + userData, err := readBEROctetString(r) + if err != nil { + return MCSProbeInfo{}, err + } + info := MCSProbeInfo{UserDataBytes: len(userData)} + parseGCCConferenceResponse(userData, &info) + return info, nil +} + +func parseGCCConferenceResponse(data []byte, info *MCSProbeInfo) { + r := bytes.NewReader(data) + _, _ = r.ReadByte() + _ = readPERObjectIdentifier(r, []byte{0, 0, 20, 124, 0, 1}) + _, _ = readPERLength(r) + _, _ = r.ReadByte() + _, _ = readBE16(r) + _, _ = readPERInteger(r) + _, _ = r.ReadByte() + _, _ = r.ReadByte() + _, _ = r.ReadByte() + if !readPEROctetStreamMatch(r, []byte("McDn"), 4) { + return + } + total, _ := readPERLength(r) + for total >= 4 && r.Len() >= 4 { + blockType, _ := readLE16(r) + blockLen, _ := readLE16(r) + if blockLen < 4 || int(blockLen-4) > r.Len() { + return + } + body := make([]byte, int(blockLen-4)) + _, _ = io.ReadFull(r, body) + total -= blockLen + parseGCCServerBlock(blockType, body, info) + } +} + +func parseGCCServerBlock(blockType uint16, body []byte, info *MCSProbeInfo) { + r := bytes.NewReader(body) + switch blockType { + case gccSCCore: + info.ServerCoreVersion, _ = readLE32(r) + case gccSCSecurity: + info.ServerEncryption, _ = readLE32(r) + info.ServerEncryptionLvl, _ = readLE32(r) + case gccSCNet: + info.ServerChannelID, _ = readLE16(r) + info.ServerChannelCount, _ = readLE16(r) + for i := 0; i < int(info.ServerChannelCount) && r.Len() >= 2; i++ { + channelID, _ := readLE16(r) + info.ServerChannelIDs = append(info.ServerChannelIDs, channelID) + } + case 0x0C04: + info.MessageChannelID, _ = readLE16(r) + } +} + +type bodyWriter interface { + Write([]byte) (int, error) + WriteByte(byte) error +} + +func writeBERApplicationTag(w bodyWriter, tag int, size int) { + w.WriteByte(0x7f) + w.WriteByte(byte(tag)) + writeBERLength(w, size) +} + +func readBERApplicationTag(r *bytes.Reader, tag int) error { + first, err := r.ReadByte() + if err != nil { + return err + } + if first != 0x7f { + return fmt.Errorf("MCS BER application tag 头错误: 0x%x", first) + } + got, err := r.ReadByte() + if err != nil { + return err + } + if int(got) != tag { + return fmt.Errorf("MCS BER application tag 不匹配: 0x%x", got) + } + _, err = readBERLength(r) + return err +} + +func writeBERDomainParameters(w bodyWriter, maxChannelIDs, maxUserIDs, maxTokenIDs, maxPDUSize int) { + body := bytes.Buffer{} + writeBERInteger(&body, maxChannelIDs) + writeBERInteger(&body, maxUserIDs) + writeBERInteger(&body, maxTokenIDs) + writeBERInteger(&body, 1) + writeBERInteger(&body, 0) + writeBERInteger(&body, 1) + writeBERInteger(&body, maxPDUSize) + writeBERInteger(&body, 2) + w.WriteByte(0x30) + writeBERLength(w, body.Len()) + w.Write(body.Bytes()) +} + +func skipBERDomainParameters(r *bytes.Reader) error { + tag, err := r.ReadByte() + if err != nil { + return err + } + if tag != 0x30 { + return fmt.Errorf("MCS DomainParameters tag 错误: 0x%x", tag) + } + size, err := readBERLength(r) + if err != nil { + return err + } + if size > r.Len() { + return io.ErrUnexpectedEOF + } + _, err = r.Seek(int64(size), io.SeekCurrent) + return err +} + +func writeBEROctetString(w bodyWriter, data []byte) { + w.WriteByte(0x04) + writeBERLength(w, len(data)) + w.Write(data) +} + +func readBEROctetString(r *bytes.Reader) ([]byte, error) { + tag, err := r.ReadByte() + if err != nil { + return nil, err + } + if tag != 0x04 { + return nil, fmt.Errorf("MCS octet string tag 错误: 0x%x", tag) + } + size, err := readBERLength(r) + if err != nil { + return nil, err + } + if size > r.Len() { + return nil, io.ErrUnexpectedEOF + } + out := make([]byte, size) + _, err = io.ReadFull(r, out) + return out, err +} + +func writeBERBoolean(w bodyWriter, value bool) { + w.WriteByte(0x01) + w.WriteByte(1) + if value { + w.WriteByte(0xff) + return + } + w.WriteByte(0) +} + +func writeBERInteger(w bodyWriter, value int) { + w.WriteByte(0x02) + switch { + case value <= 0xff: + w.WriteByte(1) + w.WriteByte(byte(value)) + case value <= 0xffff: + w.WriteByte(2) + var b [2]byte + binary.BigEndian.PutUint16(b[:], uint16(value)) + w.Write(b[:]) + default: + w.WriteByte(4) + var b [4]byte + binary.BigEndian.PutUint32(b[:], uint32(value)) + w.Write(b[:]) + } +} + +func readBERInteger(r *bytes.Reader) (int, error) { + tag, err := r.ReadByte() + if err != nil { + return 0, err + } + if tag != 0x02 { + return 0, fmt.Errorf("MCS integer tag 错误: 0x%x", tag) + } + size, err := readBERLength(r) + if err != nil { + return 0, err + } + if size > 4 || size > r.Len() { + return 0, io.ErrUnexpectedEOF + } + raw := make([]byte, size) + _, _ = io.ReadFull(r, raw) + value := 0 + for _, b := range raw { + value = value<<8 | int(b) + } + return value, nil +} + +func readBEREnumerated(r *bytes.Reader) (byte, error) { + tag, err := r.ReadByte() + if err != nil { + return 0, err + } + if tag != 0x0a { + return 0, fmt.Errorf("MCS enumerated tag 错误: 0x%x", tag) + } + size, err := readBERLength(r) + if err != nil { + return 0, err + } + if size != 1 { + return 0, fmt.Errorf("MCS enumerated 长度错误: %d", size) + } + return r.ReadByte() +} + +func writeBERLength(w bodyWriter, size int) { + if size > 0x7f { + w.WriteByte(0x82) + var b [2]byte + binary.BigEndian.PutUint16(b[:], uint16(size)) + w.Write(b[:]) + return + } + w.WriteByte(byte(size)) +} + +func readBERLength(r *bytes.Reader) (int, error) { + first, err := r.ReadByte() + if err != nil { + return 0, err + } + if first&0x80 == 0 { + return int(first), nil + } + count := int(first & 0x7f) + if count == 0 || count > 2 || count > r.Len() { + return 0, fmt.Errorf("BER length 字节数非法: %d", count) + } + value := 0 + for i := 0; i < count; i++ { + b, _ := r.ReadByte() + value = value<<8 | int(b) + } + return value, nil +} + +func writePERChoice(w bodyWriter, choice byte) { + w.WriteByte(choice) +} + +func writePERInteger(w bodyWriter, value int) { + if value <= 0xff { + writePERLength(w, 1) + w.WriteByte(byte(value)) + return + } + if value <= 0xffff { + writePERLength(w, 2) + var b [2]byte + binary.BigEndian.PutUint16(b[:], uint16(value)) + w.Write(b[:]) + return + } + writePERLength(w, 4) + var b [4]byte + binary.BigEndian.PutUint32(b[:], uint32(value)) + w.Write(b[:]) +} + +func writePERObjectIdentifier(w bodyWriter, oid []byte) { + w.WriteByte(5) + w.WriteByte((oid[0] << 4) | (oid[1] & 0x0f)) + w.Write(oid[2:6]) +} + +func readPERObjectIdentifier(r *bytes.Reader, oid []byte) bool { + size, err := r.ReadByte() + if err != nil || size != 5 || r.Len() < 5 { + return false + } + raw := make([]byte, 5) + _, _ = io.ReadFull(r, raw) + got := []byte{raw[0] >> 4, raw[0] & 0x0f, raw[1], raw[2], raw[3], raw[4]} + return bytes.Equal(got, oid) +} + +func writePERLength(w bodyWriter, size int) { + if size > 0x7f { + var b [2]byte + binary.BigEndian.PutUint16(b[:], uint16(size|0x8000)) + w.Write(b[:]) + return + } + w.WriteByte(byte(size)) +} + +func readPERLength(r *bytes.Reader) (uint16, error) { + first, err := r.ReadByte() + if err != nil { + return 0, err + } + if first&0x80 == 0 { + return uint16(first), nil + } + next, err := r.ReadByte() + if err != nil { + return 0, err + } + return uint16(first&0x7f)<<8 | uint16(next), nil +} + +func writePERNumericString(w bodyWriter, s string, minValue int) { + length := len(s) + encodedLength := minValue + if length >= minValue { + encodedLength = length - minValue + } + writePERLength(w, encodedLength) + for i := 0; i < length; i += 2 { + left := (s[i] - '0') % 10 + right := byte(0) + if i+1 < length { + right = (s[i+1] - '0') % 10 + } + w.WriteByte((left << 4) | right) + } +} + +func writePEROctetStream(w bodyWriter, data []byte, minValue int) { + encodedLength := minValue + if len(data) >= minValue { + encodedLength = len(data) - minValue + } + writePERLength(w, encodedLength) + w.Write(data) +} + +func readPEROctetStreamMatch(r *bytes.Reader, expected []byte, minValue int) bool { + encodedLength, err := readPERLength(r) + if err != nil { + return false + } + size := int(encodedLength) + minValue + if size != len(expected) || size > r.Len() { + return false + } + raw := make([]byte, size) + _, _ = io.ReadFull(r, raw) + return bytes.Equal(raw, expected) +} + +func readPERInteger(r *bytes.Reader) (uint32, error) { + size, err := readPERLength(r) + if err != nil { + return 0, err + } + if int(size) > r.Len() { + return 0, io.ErrUnexpectedEOF + } + switch size { + case 1: + b, _ := r.ReadByte() + return uint32(b), nil + case 2: + return readBE16(r) + case 4: + return readBE32(r) + default: + _, _ = r.Seek(int64(size), io.SeekCurrent) + return 0, nil + } +} + +func fixedUTF16LE(value string, size int) []byte { + out := make([]byte, size) + encoded := utf16.Encode([]rune(value)) + for i, r := range encoded { + offset := i * 2 + if offset+1 >= len(out) { + break + } + binary.LittleEndian.PutUint16(out[offset:offset+2], r) + } + return out +} + +func utf16LENull(value string) []byte { + out := bytes.Buffer{} + for _, r := range utf16.Encode([]rune(value)) { + writeLE16(&out, r) + } + writeLE16(&out, 0) + return out.Bytes() +} + +func writeLE16(w io.Writer, value uint16) { + var b [2]byte + binary.LittleEndian.PutUint16(b[:], value) + _, _ = w.Write(b[:]) +} + +func writeLE32(w io.Writer, value uint32) { + var b [4]byte + binary.LittleEndian.PutUint32(b[:], value) + _, _ = w.Write(b[:]) +} + +func readLE16(r io.Reader) (uint16, error) { + var b [2]byte + _, err := io.ReadFull(r, b[:]) + return binary.LittleEndian.Uint16(b[:]), err +} + +func readLE32(r io.Reader) (uint32, error) { + var b [4]byte + _, err := io.ReadFull(r, b[:]) + return binary.LittleEndian.Uint32(b[:]), err +} + +func readBE16(r io.Reader) (uint32, error) { + var b [2]byte + _, err := io.ReadFull(r, b[:]) + return uint32(binary.BigEndian.Uint16(b[:])), err +} + +func readBE32(r io.Reader) (uint32, error) { + var b [4]byte + _, err := io.ReadFull(r, b[:]) + return binary.BigEndian.Uint32(b[:]), err +} + +func writeMCSPDUHeader(w bodyWriter, pduType byte) { + w.WriteByte(pduType << 2) +} + +func readMCSPDUHeader(r *bytes.Reader) (byte, error) { + header, err := r.ReadByte() + if err != nil { + return 0, err + } + return header >> 2, nil +} + +func writeBE16Value(w bodyWriter, value uint16) { + var b [2]byte + binary.BigEndian.PutUint16(b[:], value) + w.Write(b[:]) +} + +func readBE16Value(r io.Reader) (uint16, error) { + var b [2]byte + _, err := io.ReadFull(r, b[:]) + return binary.BigEndian.Uint16(b[:]), err +} diff --git a/backend/pkg/rdp/mppc.go b/backend/pkg/rdp/mppc.go new file mode 100644 index 0000000..375dceb --- /dev/null +++ b/backend/pkg/rdp/mppc.go @@ -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 +} \ No newline at end of file diff --git a/backend/pkg/rdp/packet_io.go b/backend/pkg/rdp/packet_io.go new file mode 100644 index 0000000..04f7d34 --- /dev/null +++ b/backend/pkg/rdp/packet_io.go @@ -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) +} diff --git a/backend/pkg/rdp/rle/bpp1.go b/backend/pkg/rdp/rle/bpp1.go new file mode 100644 index 0000000..d7fa650 --- /dev/null +++ b/backend/pkg/rdp/rle/bpp1.go @@ -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 */ diff --git a/backend/pkg/rdp/rle/bpp2.go b/backend/pkg/rdp/rle/bpp2.go new file mode 100644 index 0000000..cc88e9f --- /dev/null +++ b/backend/pkg/rdp/rle/bpp2.go @@ -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 */ diff --git a/backend/pkg/rdp/rle/bpp3.go b/backend/pkg/rdp/rle/bpp3.go new file mode 100644 index 0000000..de94a69 --- /dev/null +++ b/backend/pkg/rdp/rle/bpp3.go @@ -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 */ diff --git a/backend/pkg/rdp/rle/bpp4.go b/backend/pkg/rdp/rle/bpp4.go new file mode 100644 index 0000000..d0d3062 --- /dev/null +++ b/backend/pkg/rdp/rle/bpp4.go @@ -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. diff --git a/backend/pkg/rdp/rle/entry.go b/backend/pkg/rdp/rle/entry.go new file mode 100644 index 0000000..eb4488f --- /dev/null +++ b/backend/pkg/rdp/rle/entry.go @@ -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 +} diff --git a/backend/pkg/rdp/rle/util.go b/backend/pkg/rdp/rle/util.go new file mode 100644 index 0000000..e5b0e23 --- /dev/null +++ b/backend/pkg/rdp/rle/util.go @@ -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++ + } +} \ No newline at end of file diff --git a/backend/pkg/rdp/session_connect.go b/backend/pkg/rdp/session_connect.go new file mode 100644 index 0000000..ff78e16 --- /dev/null +++ b/backend/pkg/rdp/session_connect.go @@ -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 +} diff --git a/backend/pkg/rdp/session_engine.go b/backend/pkg/rdp/session_engine.go new file mode 100644 index 0000000..caeae0f --- /dev/null +++ b/backend/pkg/rdp/session_engine.go @@ -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 +} diff --git a/backend/pkg/rdp/trace/trace.go b/backend/pkg/rdp/trace/trace.go new file mode 100644 index 0000000..9a6ef7c --- /dev/null +++ b/backend/pkg/rdp/trace/trace.go @@ -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...) +} diff --git a/backend/pkg/rdp/transport/tls.go b/backend/pkg/rdp/transport/tls.go new file mode 100644 index 0000000..29b854f --- /dev/null +++ b/backend/pkg/rdp/transport/tls.go @@ -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) +} diff --git a/backend/pkg/rdp/transport/tpkt.go b/backend/pkg/rdp/transport/tpkt.go new file mode 100644 index 0000000..29881b7 --- /dev/null +++ b/backend/pkg/rdp/transport/tpkt.go @@ -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 +} diff --git a/backend/pkg/rdp/transport/x224.go b/backend/pkg/rdp/transport/x224.go new file mode 100644 index 0000000..147963f --- /dev/null +++ b/backend/pkg/rdp/transport/x224.go @@ -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 +} diff --git a/backend/pkg/rdpfilestage/stage.go b/backend/pkg/rdpfilestage/stage.go new file mode 100644 index 0000000..c8fe85d --- /dev/null +++ b/backend/pkg/rdpfilestage/stage.go @@ -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 +} \ No newline at end of file diff --git a/backend/pkg/systask/registry.go b/backend/pkg/systask/registry.go index 3a3434b..cb59042 100644 --- a/backend/pkg/systask/registry.go +++ b/backend/pkg/systask/registry.go @@ -8,6 +8,7 @@ import ( "ezops/model" "ezops/pkg/auditcleaner" + "ezops/pkg/billingsync" "ezops/pkg/database" snmppkg "ezops/pkg/snmp" @@ -66,6 +67,11 @@ var ( Description: "清理 7 天前的 SNMP 采集指标历史,防止表膨胀", 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) err = r.Error 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: return Item{}, fmt.Errorf("任务 %s 未实现手动执行", code) } diff --git a/backend/pkg/winsmb/client.go b/backend/pkg/winsmb/client.go new file mode 100644 index 0000000..b23ff75 --- /dev/null +++ b/backend/pkg/winsmb/client.go @@ -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 +} \ No newline at end of file diff --git a/backend/pkg/wshub/manager.go b/backend/pkg/wshub/manager.go index 2a737c7..c8df5d5 100644 --- a/backend/pkg/wshub/manager.go +++ b/backend/pkg/wshub/manager.go @@ -23,12 +23,13 @@ import ( // Connection types — identifies which feature a WebSocket serves. const ( - TypeTerminal = "terminal" // WebShell SSH terminal - TypeKubectl = "kubectl" // kubectl command terminal - TypePodExec = "pod_exec" // K8s pod exec terminal - TypeAudit = "audit" // Audit real-time stream (live monitor) - TypeMetrics = "metrics" // Dashboard metrics push - TypePipeline = "pipeline" // Pipeline run log stream + TypeTerminal = "terminal" // WebShell SSH terminal + TypeRDP = "rdp" // RDP 图形会话 // Logan.Li + TypeKubectl = "kubectl" // kubectl command terminal + TypePodExec = "pod_exec" // K8s pod exec terminal + TypeAudit = "audit" // Audit real-time stream (live monitor) + TypeMetrics = "metrics" // Dashboard metrics push + TypePipeline = "pipeline" // Pipeline run log stream ) // Config tunes the manager behavior. @@ -88,8 +89,8 @@ type WSManager struct { conns sync.Map // id → *ConnInfo next uint64 // atomic counter for connection IDs - mu sync.Mutex - closed bool + mu sync.Mutex + closed bool } // New creates a WSManager with the given config. diff --git a/backend/router/agent_static.go b/backend/router/agent_static.go new file mode 100644 index 0000000..b367a0f --- /dev/null +++ b/backend/router/agent_static.go @@ -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 ", 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) +} diff --git a/backend/router/routes.go b/backend/router/routes.go index b8eeb51..20c19f4 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -1,17 +1,16 @@ package router import ( + "ezops/config" "ezops/controller" "ezops/middleware" - "ezops/config" "ezops/model" - "ezops/service" ezredis "ezops/pkg/redis" "ezops/pkg/siteprobe" "ezops/pkg/systask" "ezops/pkg/toolinstaller" "ezops/pkg/wshub" - "fmt" + "ezops/service" "strconv" "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) - r.Static("/agent/bin", "./agent/bin") - 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 ", base, base), - }) - }) + registerAgentDownloads(r, log) // === A3: 统一 WebSocket 连接管理器 === // Logan.Li // 所有 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) webShellHandler := controller.NewWebShellHandler(db, auditHub, wsMgr) 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 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/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 签名验证) pipelineHandler := controller.NewPipelineHandler(db, redisCache, ti) 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", serverHandlerV2.BatchCreateServers) - // Agent 远程安装 // Logan.Li - servers.POST("/:id/install-agent", agentInstallHandler.InstallAgent) - servers.POST("/install-agent", agentInstallHandler.BatchInstallAgent) + // Agent 远程安装 // Logan.Li + servers.POST("/:id/install-agent", agentInstallHandler.InstallAgent) + servers.POST("/install-agent", agentInstallHandler.BatchInstallAgent) - // SFTP 文件浏览 // Logan.Li - sftpHandler := controller.NewSFTPHandler(db) - servers.GET("/:id/files", sftpHandler.ListFiles) - servers.GET("/:id/files/download", sftpHandler.DownloadFile) - servers.POST("/:id/files/upload", sftpHandler.UploadFile) - servers.DELETE("/:id/files", sftpHandler.DeleteFile) - servers.POST("/:id/files/mkdir", sftpHandler.Mkdir) - } + // SFTP 文件浏览 // Logan.Li + sftpHandler := controller.NewSFTPHandler(db) + servers.GET("/:id/files", sftpHandler.ListFiles) + servers.GET("/:id/files/download", sftpHandler.DownloadFile) + servers.POST("/:id/files/upload", sftpHandler.UploadFile) + servers.DELETE("/:id/files", sftpHandler.DeleteFile) + 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) @@ -295,11 +283,12 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c cloudAccounts.DELETE("/:id", cloudAccountHandler.DeleteCloudAccount) // 云同步路由 — 区域同步 / 主机发现 / 导入主机 // Logan.Li - syncHandler := controller.NewCloudSyncHandler(db) + syncHandler := controller.NewCloudSyncHandler(db, cfg.JWT.Secret) cloudAccounts.POST("/:id/sync-regions", syncHandler.SyncRegions) cloudAccounts.GET("/:id/regions", syncHandler.GetAccountRegions) cloudAccounts.POST("/:id/discover-instances", syncHandler.DiscoverInstances) 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.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.GET("/stats", billingHandler.GetBillingStats) billings.GET("/providers", billingHandler.GetProviderOptions) + billings.POST("/sync", billingHandler.SyncYesterdayBillings) } 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.PUT("/:id", k8sHandler.UpdateCluster) clusters.DELETE("/:id", k8sHandler.DeleteCluster) - clusters.POST("/:id/sync", k8sHandler.SyncCluster) // 同步集群资源 // Logan.Li - 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/pods", k8sHandler.ListPods) // Deprecated: use GET /kubernetes/:clusterCode/pods - 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/ingresses", k8sHandler.ListIngresses) // Deprecated: use GET /kubernetes/:clusterCode/ingresses - clusters.GET("/:id/topology", k8sHandler.GetTopology) // 获取拓扑布局 // Logan.Li - clusters.PUT("/:id/topology", k8sHandler.SaveTopology) // 保存拓扑布局 // 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/:name", k8sHandler.GetNodeDetail) // M10 节点详情 // Logan.Li + 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/services", k8sHandler.ListServices) // Deprecated: use GET /kubernetes/:clusterCode/services + clusters.GET("/:id/ingresses", k8sHandler.ListIngresses) // Deprecated: use GET /kubernetes/:clusterCode/ingresses + clusters.GET("/:id/topology", k8sHandler.GetTopology) // 获取拓扑布局 // Logan.Li + clusters.PUT("/:id/topology", k8sHandler.SaveTopology) // 保存拓扑布局 // Logan.Li // Pod 日志 // Logan.Li 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.POST("", pipelineHandler.CreateRelease) 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/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) } + // 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 scHandler := controller.NewSessionCommandHandler(db) 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) } - // Agent 二进制下载(无需鉴权,Agent 升级时使用) - r.Static("/api/agent/bin", "/app/agent/bin") - // AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li llmHandler := controller.NewLLMHandler(db) aiopsGroup := authorized.Group("/aiops") diff --git a/backend/seed/seed.go b/backend/seed/seed.go index 12bbdac..b69f4cd 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -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"}]`, Enabled: true, - }, - { + }, + { Name: "应用部署申请", Code: "app_deploy", 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"}]`, Enabled: true, }, - { + { Name: "权限申请", Code: "permission_request", 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"}]`, Enabled: true, - }, + }, } if err := db.Create(&templates).Error; err != nil { zap.L().Warn("failed to seed approval templates", zap.Error(err)) @@ -171,7 +171,7 @@ func SeedData(db *gorm.DB) { // AIOps {Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, 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: "大模型设置", Code: "aiops:llm-config", Type: "menu", Sort: 64, Status: 1}, // 资源申请 @@ -193,6 +193,8 @@ func SeedData(db *gorm.DB) { db.Create(&perms) zap.L().Info("seeded default permissions") } + // 兼容旧库菜单命名,将容量预测统一更名为预测分析。 // Logan.Li + db.Model(&model.Permission{}).Where("code = ?", "aiops:forecast").Update("name", "预测分析") // 5. 创建默认角色(如果不存在) var roleCount int64 diff --git a/backend/server b/backend/server deleted file mode 100755 index c7afeac..0000000 Binary files a/backend/server and /dev/null differ diff --git a/backend/service/service.go b/backend/service/service.go index d77c53c..f749eea 100644 --- a/backend/service/service.go +++ b/backend/service/service.go @@ -9,9 +9,9 @@ import ( "ezops/dto" "ezops/model" - "ezops/repository" "ezops/pkg/crypto" ezredis "ezops/pkg/redis" + "ezops/repository" "gorm.io/gorm" ) @@ -60,6 +60,9 @@ func (s *ServerService) CreateServer(req *dto.CreateServerRequest) (*model.Serve Port: req.Port, Username: req.Username, AuthType: req.AuthType, + OSType: req.OSType, + Protocol: req.Protocol, + RDPPort: req.RDPPort, OS: req.OS, OSVersion: req.OSVersion, Arch: req.Arch, @@ -80,6 +83,18 @@ func (s *ServerService) CreateServer(req *dto.CreateServerRequest) (*model.Serve if req.AuthType == "" { 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 密钥 if req.Password != "" { @@ -96,6 +111,11 @@ func (s *ServerService) CreateServer(req *dto.CreateServerRequest) (*model.Serve } 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 { return nil, err @@ -125,6 +145,15 @@ func (s *ServerService) UpdateServer(id uint, req *dto.UpdateServerRequest) erro if req.AuthType != nil { 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 { updates["os"] = *req.OS } @@ -174,6 +203,23 @@ func (s *ServerService) UpdateServer(id uint, req *dto.UpdateServerRequest) erro } 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 { return nil @@ -181,6 +227,67 @@ func (s *ServerService) UpdateServer(id uint, req *dto.UpdateServerRequest) erro 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 删除服务器 func (s *ServerService) DeleteServer(id uint) error { return s.repo.Delete(id) @@ -237,6 +344,19 @@ func (s *CredentialService) buildCredentialContent(req *dto.CreateCredentialRequ } b, _ := json.Marshal(data) 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": data := map[string]string{ "server": req.DockerServer, @@ -309,6 +429,25 @@ func (s *CredentialService) buildUpdateContent(req *dto.UpdateCredentialRequest, } b, _ := json.Marshal(data) 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": data := map[string]string{} if req.DockerServer != nil { @@ -523,9 +662,9 @@ func toJSONString(v interface{}) string { // ━━━ Config Helper — 管理所有 Service 的依赖注入 ━━━━ type Services struct { - Server *ServerService - Credential *CredentialService - AlertContact *AlertContactService + Server *ServerService + Credential *CredentialService + AlertContact *AlertContactService AlertContactGroup *AlertContactGroupService } @@ -534,9 +673,9 @@ func NewServices(db *gorm.DB, cache ezredis.Cache, masterKey string) *Services { crypt := crypto.NewSecretBox(masterKey) return &Services{ - Server: NewServerService(repository.NewServerRepository(db), cache, crypt), - Credential: NewCredentialService(repository.NewCredentialRepository(db), crypt), - AlertContact: NewAlertContactService(repository.NewAlertContactRepository(db)), + Server: NewServerService(repository.NewServerRepository(db), cache, crypt), + Credential: NewCredentialService(repository.NewCredentialRepository(db), crypt), + AlertContact: NewAlertContactService(repository.NewAlertContactRepository(db)), AlertContactGroup: NewAlertContactGroupService(repository.NewAlertContactGroupRepository(db)), } } diff --git a/docker-compose.yml b/docker-compose.yml index 3d7f19b..697278f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,6 +85,9 @@ services: REDIS_HOST: redis REDIS_PORT: 6379 JWT_SECRET: ${JWT_SECRET:-aiops-jwt-secret-change-me} + # RDP 使用自研协议栈,第三方 grdp 已移除;该变量保留兼容但后端不再读取。 // Logan.Li + RDP_ENGINE: ${RDP_ENGINE:-rdp} + RDP_DEBUG: ${RDP_DEBUG:-1} volumes: - aiops_uploads:/app/uploads - /var/run/docker.sock:/var/run/docker.sock @@ -109,4 +112,4 @@ volumes: networks: aiops-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docs/README.md b/docs/README.md index 2d08e6a..5b0f53f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ | [API 接口](./api-reference.md) | 后端 API 接口文档 | | [数据库设计](./database-schema.md) | 数据模型和表结构 | | [开发日志](./dev-log.md) | 详细开发日志(按日期记录) | +| [Web RDP 远程桌面](./rdp.md) | 自研 RDP 协议栈、WebSocket 帧协议(归档:`/opt/rdp/`) | | [Redis 缓存策略](./redis-caching-strategy.md) | Redis 缓存使用现状与优化方案 | ## 设计文档 @@ -45,6 +46,7 @@ | 日志告警 | AlertEngine 支持 `type=log` 规则,查询 Loki / ES 触发/恢复 | | 远程执行 | 新增远程执行页(执行/上传),移除 ansible 命名 | | 大模型双协议 | OpenAI 兼容 + Anthropic Messages 协议接入 | +| Web RDP 自研引擎 | 移除 grdp,SessionEngine 完整图形会话,见 [rdp.md](./rdp.md) | ## 部署速查 diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..fca7ad1 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,51 @@ +# 前端视觉与页面规范 + +本文档约定 AIOps 控制台的页面结构、视觉基础和复用组件。新增页面优先使用这些规范,已有页面逐步迁移。 + +## 设计目标 + +- 页面打开即用,避免营销式大标题和多余说明。 +- 信息密度适中,优先支持扫描、筛选、对比和批量操作。 +- 主题感知,颜色优先使用 Tailwind 语义色和 `var(--dg-*)` 变量。 +- 同类页面保持一致:列表、详情、弹窗、空态、加载态和错误态不重复造轮子。 + +## 页面结构 + +标准业务页使用 `PageShell`: + +1. `PageHeader`:标题、简短描述、主操作按钮。 +2. `FilterBar`:搜索、筛选、刷新、批量操作。 +3. 主内容区:表格、图表、详情或编辑器。 +4. `PageSection`:页面内分组区域。 + +示例: + +```tsx + + + + {table} + +``` + +## 状态与数据展示 + +- 状态统一使用 `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`。 +- 新增颜色进入主题变量,不在业务组件硬编码十六进制颜色。 diff --git a/docs/dev-log.md b/docs/dev-log.md index 1b2630f..8795996 100644 --- a/docs/dev-log.md +++ b/docs/dev-log.md @@ -1,5 +1,43 @@ # 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 设计系统 ### 完成项 diff --git a/docs/development-guide.md b/docs/development-guide.md index 0d44cd4..45005f2 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -226,7 +226,7 @@ const { list, total } = await getServers({ page: 1 }); ```bash cd backend go vet ./... -go build -o aiops-server . +go build -o aiops . ``` ### 前端构建测试 @@ -262,4 +262,4 @@ python3 tests/integration_test.py --host localhost --port 8080 ```go log.Info("server started", zap.String("port", cfg.Port)) log.Error("database error", zap.Error(err)) -``` \ No newline at end of file +``` diff --git a/docs/rdp.md b/docs/rdp.md new file mode 100644 index 0000000..fa7197c --- /dev/null +++ b/docs/rdp.md @@ -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` | 下载 | diff --git a/docs/terminal-architecture.md b/docs/terminal-architecture.md new file mode 100644 index 0000000..e92fcb7 --- /dev/null +++ b/docs/terminal-architecture.md @@ -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 +
+``` + +全局样式位置:`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 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index bc3df9a..c852688 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -35,6 +35,14 @@ export default defineConfig([ 'error', { 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', }, }, ]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d1e3851..dc8d20d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { - "name": "my-app", - "version": "0.0.0", + "name": "aiops-frontend", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "my-app", - "version": "0.0.0", + "name": "aiops-frontend", + "version": "0.1.0", "dependencies": { "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-yaml": "^6.1.3", diff --git a/frontend/package.json b/frontend/package.json index 3c4a085..9e4e986 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,11 +1,14 @@ { - "name": "my-app", + "name": "aiops-frontend", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "typecheck": "tsc --noEmit", + "check": "npm run typecheck", + "check:strict": "npm run lint && npm run typecheck", "lint": "eslint .", "preview": "vite preview" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f5afc2..c392693 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,8 +16,7 @@ const Dashboard = lazy(() => import('@/components/dashboard/Dashboard').then(m = const ServerManager = lazy(() => import('@/components/server/ServerManager').then(m => ({ default: m.ServerManager }))); const CloudAccountManager = lazy(() => import('@/components/cloud/CloudAccountManager').then(m => ({ default: m.CloudAccountManager }))); const DomainManager = lazy(() => import('@/components/cloud/DomainManager').then(m => ({ default: m.DomainManager }))); -const RegionManager = lazy(() => import('@/components/cloud/RegionManager').then(m => ({ default: m.RegionManager }))); -const PlatformManager = lazy(() => import('@/components/cloud/PlatformManager').then(m => ({ default: m.PlatformManager }))); + const CloudBilling = lazy(() => import('@/components/cloud/CloudBilling').then(m => ({ default: m.CloudBilling }))); const KubernetesClusters = lazy(() => import('@/components/kubernetes/KubernetesClusters').then(m => ({ default: m.KubernetesClusters }))); const ClusterOverviewPage = lazy(() => import('@/components/kubernetes/ClusterOverviewPage').then(m => ({ default: m.default }))); @@ -123,8 +122,8 @@ function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> {/* Kubernetes Routes */} diff --git a/frontend/src/api/aiops/agent-cli.ts b/frontend/src/api/aiops/agent-cli.ts index bbcbcbd..a78503c 100644 --- a/frontend/src/api/aiops/agent-cli.ts +++ b/frontend/src/api/aiops/agent-cli.ts @@ -6,9 +6,15 @@ import { useUserStore } from '@/store'; export interface AgentCliSession { id: number; user_id: number; + mode: string; // host | kubernetes server_id: number; server_name: string; server_host: string; + cluster_id: number; + cluster_name: string; + namespace: string; + pod_name: string; + container: string; title: string; status: string; // running/done/aborted/error provider: string; @@ -38,25 +44,56 @@ export interface AgentCliMessage { created_at: string; } +export type AgentCliMode = 'host' | 'kubernetes'; + +export type AgentCliWsParams = + | { mode?: 'host'; serverId: number | string } + | { mode: 'kubernetes'; cluster: string; namespace: string; pod?: string; container?: string }; + export const agentCliApi = { - // 会话列表(按当前用户隔离) - getSessions: async (params?: { server_id?: number; page?: number; page_size?: number }) => { + getSessions: async (params?: { + mode?: AgentCliMode; + server_id?: number; + cluster_id?: number; + namespace?: string; + page?: number; + page_size?: number; + }) => { return http.get<{ list: AgentCliSession[]; total: number }>('/aiops/agent-cli/sessions', params); }, - // 会话详情(含完整推理消息,供回放) getSession: async (id: number | string) => { return http.get<{ session: AgentCliSession; messages: AgentCliMessage[] }>(`/aiops/agent-cli/sessions/${id}`); }, - // 删除会话 deleteSession: async (id: number | string) => { return http.delete(`/aiops/agent-cli/sessions/${id}`); }, }; -// buildAgentCliWsUrl 构造 Agent CLI WebSocket 地址(浏览器无法设 header,用 ?token= 回退) // Logan.Li -export function buildAgentCliWsUrl(serverId: number | string): string { - const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws'; - // Token 双保险:store 优先,localStorage 兜底 // Logan.Li - const token = useUserStore.getState().token || localStorage.getItem('token') || ''; - return `${proto}://${location.host}/ws/agent-cli?id=${serverId}&token=${encodeURIComponent(token)}`; +function wsToken() { + return useUserStore.getState().token || localStorage.getItem('token') || ''; } + +// buildAgentCliWsUrl 构造 Agent CLI WebSocket 地址 // Logan.Li +export function buildAgentCliWsUrl(params: AgentCliWsParams | number | string): string { + const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws'; + const token = encodeURIComponent(wsToken()); + const base = `${proto}://${location.host}/ws/agent-cli`; + + if (typeof params === 'number' || typeof params === 'string') { + return `${base}?mode=host&id=${params}&token=${token}`; + } + + if (params.mode === 'kubernetes') { + const qs = new URLSearchParams({ + mode: 'kubernetes', + cluster: params.cluster, + namespace: params.namespace, + token: wsToken(), + }); + if (params.pod) qs.set('pod', params.pod); + if (params.container) qs.set('container', params.container); + return `${base}?${qs.toString()}`; + } + + return `${base}?mode=host&id=${params.serverId}&token=${token}`; +} \ No newline at end of file diff --git a/frontend/src/api/audit/index.ts b/frontend/src/api/audit/index.ts index aa9f2ad..cafab78 100644 --- a/frontend/src/api/audit/index.ts +++ b/frontend/src/api/audit/index.ts @@ -1,4 +1,5 @@ // 安全审计 API 聚合出口 // Logan.Li export * from './audit'; +export * from './rdp'; export * from './terminal'; diff --git a/frontend/src/api/audit/rdp.ts b/frontend/src/api/audit/rdp.ts new file mode 100644 index 0000000..0b23340 --- /dev/null +++ b/frontend/src/api/audit/rdp.ts @@ -0,0 +1,22 @@ +// Windows RDP 图形会话审计 API // Logan.Li + +import { http, type ListResponse } from '@/utils/request'; + +export const rdpSessionApi = { + list: async (params?: { + page?: number; + page_size?: number; + server_id?: number; + username?: string; + status?: string; + keyword?: string; + start_date?: string; + end_date?: string; + has_record?: string | boolean; + }) => http.get('/rdp-sessions', params as Record), + get: async (id: number) => http.get(`/rdp-sessions/${id}`), + getRecord: async (id: number) => http.get(`/rdp-sessions/${id}/record`), + kill: async (id: number) => http.post(`/rdp-sessions/${id}/kill`), + download: async (id: number) => http.download(`/rdp-sessions/${id}/download`, `rdp-session-${id}.json`), + logReplayView: async (id: number) => http.post(`/rdp-sessions/${id}/replay-view`), +}; diff --git a/frontend/src/api/cloud/account.ts b/frontend/src/api/cloud/account.ts index f962544..ebfa3a6 100644 --- a/frontend/src/api/cloud/account.ts +++ b/frontend/src/api/cloud/account.ts @@ -24,9 +24,17 @@ export const cloudApi = { discoverInstances: async (id: number | string, regions: string[]) => { return http.post<{ list: Record[]; total: number; errors: string[] }>(`/cloud-accounts/${id}/discover-instances`, { regions }); }, - importInstances: async (id: number | string, instances: Record[], opts?: { default_port?: number; credential_id?: number }) => { + importInstances: async ( + id: number | string, + instances: Record[], + opts?: { default_port?: number; credential_id?: number; ssh_credential_id?: number; rdp_credential_id?: number }, + ) => { return http.post<{ imported: number; skipped: number }>(`/cloud-accounts/${id}/import-instances`, { instances, ...opts }); }, + /** 同步指定云账号账单(默认昨日) // Logan.Li */ + syncBilling: async (id: number | string, params?: { date?: string }) => { + return http.post(`/cloud-accounts/${id}/sync-billing`, params || {}); + }, getDomains: async (params?: { page?: number; page_size?: number; keyword?: string }) => { return http.get('/domains', params); }, @@ -39,4 +47,4 @@ export const cloudApi = { deleteDomain: async (id: number | string) => { return http.delete(`/domains/${id}`); }, -}; \ No newline at end of file +}; diff --git a/frontend/src/api/cloud/billing.ts b/frontend/src/api/cloud/billing.ts index 70eb9f4..fb1d612 100644 --- a/frontend/src/api/cloud/billing.ts +++ b/frontend/src/api/cloud/billing.ts @@ -6,7 +6,7 @@ export const billingApi = { getStats: async () => { return http.get('/cloud-billings/stats'); }, - list: async (params?: { provider?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => { + list: async (params?: { provider?: string; cloud_account_id?: number; start_date?: string; end_date?: string; page?: number; page_size?: number }) => { return http.get('/cloud-billings', params); }, create: async (data: Record) => { @@ -21,6 +21,17 @@ export const billingApi = { getProviders: async () => { return http.get('/cloud-billings/providers'); }, + /** 从云账号 AK 同步账单(默认昨日) // Logan.Li */ + sync: async (params?: { account_id?: number; date?: string }) => { + return http.post<{ + billing_date: string; + accounts: number; + created: number; + updated: number; + skipped: number; + errors: string[]; + }>('/cloud-billings/sync', params || {}); + }, listAlertRules: async () => { return http.get('/billing-alerts'); }, diff --git a/frontend/src/api/monitor/grafana.ts b/frontend/src/api/monitor/grafana.ts deleted file mode 100644 index 5e7a5a8..0000000 --- a/frontend/src/api/monitor/grafana.ts +++ /dev/null @@ -1,56 +0,0 @@ -// 自定义监控仪表盘 API // Logan.Li - -import { http } from '@/utils/request'; -import type { GrafanaDashboard } from '@/types/monitor'; - -export const dashboardApi = { - getDashboards: async (): Promise<{ data: GrafanaDashboard[]; total: number }> => { - const res = await http.get('/custom-dashboards'); - // http 自动解包 .data,后端返回 {list, total} - const body = (res as Record) ?? {}; - const list = (body.list as GrafanaDashboard[]) ?? []; - const total = (body.total as number) ?? list.length; - return { data: list, total }; - }, - getDatasources: async (): Promise<{ data: Record[]; total: number }> => { - // 复用 Prometheus 数据源列表 - const res = await http.get('/prometheus-datasources'); - const arr = (res as Record[]) ?? []; - return { data: arr, total: arr.length }; - }, - createDashboard: async (data: Partial): Promise<{ success: boolean }> => { - // 将 panels 数组序列化为 JSON 字符串 - const payload: Record = { - name: data.name ?? '', - description: data.description ?? '', - datasource: data.datasource ?? 'prometheus', - datasource_url: data.datasourceUrl ?? '', - panels: JSON.stringify(data.panels ?? []), - is_default: data.isDefault ?? false, - }; - await http.post('/custom-dashboards', payload); - return { success: true }; - }, - updateDashboard: async (id: number | string, data: Partial): Promise<{ success: boolean }> => { - const payload: Record = {}; - if (data.name !== undefined) payload.name = data.name; - if (data.description !== undefined) payload.description = data.description; - if (data.datasource !== undefined) payload.datasource = data.datasource; - if (data.datasourceUrl !== undefined) payload.datasource_url = data.datasourceUrl; - if (data.panels !== undefined) payload.panels = JSON.stringify(data.panels); - if (data.isDefault !== undefined) payload.is_default = data.isDefault; - await http.put(`/custom-dashboards/${id}`, payload); - return { success: true }; - }, - deleteDashboard: async (id: number | string): Promise<{ success: boolean }> => { - await http.delete(`/custom-dashboards/${id}`); - return { success: true }; - }, - setDefault: async (id: number | string): Promise<{ success: boolean }> => { - await http.post(`/custom-dashboards/${id}/default`); - return { success: true }; - }, - createDatasource: async (_data: Record): Promise<{ success: boolean }> => { - return { success: false }; - }, -}; diff --git a/frontend/src/api/monitor/index.ts b/frontend/src/api/monitor/index.ts index 6ad17df..b02fe5b 100644 --- a/frontend/src/api/monitor/index.ts +++ b/frontend/src/api/monitor/index.ts @@ -7,6 +7,5 @@ export { alertMetricsApi } from './alert-metrics'; export type { MetricCatalogItem, MetricCatalogCategory, UnifiedAlertRule } from './alert-metrics'; export { traceApi } from './trace'; export { traceDatasourceApi, type TraceDataSource } from './trace-datasource'; -export { dashboardApi } from './grafana'; export { promDatasourceApi, zabbixDatasourceApi } from './datasource'; -export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi, middlewareMonitorApi } from './metrics'; \ No newline at end of file +export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi, middlewareMonitorApi } from './metrics'; diff --git a/frontend/src/api/server/rdp-files.ts b/frontend/src/api/server/rdp-files.ts new file mode 100644 index 0000000..f99af77 --- /dev/null +++ b/frontend/src/api/server/rdp-files.ts @@ -0,0 +1,38 @@ +import http from '@/utils/request'; + +export interface RdpFileItem { + name: string; + size: number; + is_dir: boolean; + mod_time: string; +} + +export interface RdpFileListData { + root: string; + list: RdpFileItem[]; + total: number; +} + +export interface RdpStageResult { + token: string; + download_url: string; + remote_path: string; + deploy_command: string; + mode: string; +} + +export const rdpFilesApi = { + list: async (serverId: number) => http.get(`/servers/${serverId}/rdp-files`), + upload: async (serverId: number, file: File) => { + const fd = new FormData(); + fd.append('file', file); + return http.upload<{ name: string; path: string; size: number }>(`/servers/${serverId}/rdp-files/upload`, fd); + }, + stage: async (serverId: number, file: File) => { + const fd = new FormData(); + fd.append('file', file); + return http.upload(`/servers/${serverId}/rdp-files/stage`, fd); + }, + download: async (serverId: number, name: string) => + http.download(`/servers/${serverId}/rdp-files/download?name=${encodeURIComponent(name)}`, name), +}; \ No newline at end of file diff --git a/frontend/src/api/server/server.ts b/frontend/src/api/server/server.ts index 41a3bde..5d3c9fe 100644 --- a/frontend/src/api/server/server.ts +++ b/frontend/src/api/server/server.ts @@ -24,7 +24,15 @@ export const serverApi = { batchDelete: async (ids: number[]) => { return http.post('/servers/batch-delete', { ids }); }, - batchCreate: async (data: { servers: Record[]; region_id?: string; auth_type?: string; credential_id?: number; default_port?: number }) => { + batchCreate: async (data: { + servers: Record[]; + region_id?: string; + auth_type?: string; + credential_id?: number; + ssh_credential_id?: number; + rdp_credential_id?: number; + default_port?: number; + }) => { return http.post('/servers/batch', data); }, getMetrics: async (_id: number | string) => { @@ -57,4 +65,4 @@ export const serverApi = { mkdir: async (serverId: number, dirPath: string) => { return http.post(`/servers/${serverId}/files/mkdir`, { path: dirPath }); }, -}; \ No newline at end of file +}; diff --git a/frontend/src/components/aiops/AIAlertAnalyzer.tsx b/frontend/src/components/aiops/AIAlertAnalyzer.tsx index 520a7e9..53f919a 100644 --- a/frontend/src/components/aiops/AIAlertAnalyzer.tsx +++ b/frontend/src/components/aiops/AIAlertAnalyzer.tsx @@ -34,7 +34,7 @@ import { useTranslation } from 'react-i18next'; function parseAIResponse(raw: string, fallback: AIAnalysis): AIAnalysis { try { // Strip markdown code fences if present - let json = raw.replace(/```(?:json)?\s*\n?/g, '').replace(/```$/, '').trim(); + const json = raw.replace(/```(?:json)?\s*\n?/g, '').replace(/```$/, '').trim(); const parsed = JSON.parse(json); return { rootCause: parsed.root_cause || parsed.rootCause || fallback.rootCause, diff --git a/frontend/src/components/aiops/AIPredictiveAnalytics.tsx b/frontend/src/components/aiops/AIPredictiveAnalytics.tsx index 4fa58ba..e8211b0 100644 --- a/frontend/src/components/aiops/AIPredictiveAnalytics.tsx +++ b/frontend/src/components/aiops/AIPredictiveAnalytics.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { StatStrip } from '@/components/ui/stat-strip'; import { Button } from '@/components/ui/button'; @@ -15,7 +15,6 @@ import { Brain, TrendingUp, TrendingDown, - AlertTriangle, HardDrive, Cpu, MemoryStick, @@ -23,36 +22,36 @@ import { Activity, Calendar, Sparkles, - Zap, Minus, Loader2, } from 'lucide-react'; -import { dashboardStatsApi, serverApi, alertApi } from '@/api'; +import { dashboardStatsApi, serverApi, billingApi } from '@/api'; import { PredictiveCapacityTab } from './PredictiveCapacityTab'; +import { PredictiveCostTab } from './PredictiveCostTab'; import { computePredictions, + computeCostPredictions, trendColorClass, type CapacityForecast, } from './predictUtils'; import { PageShell } from '@/components/layout/PageShell'; +import { formatCurrency } from '@/components/cloud/CloudBillingHelpers'; import type { JsonData } from '@/types/common'; -import { useTranslation } from 'react-i18next'; // 重新导出 CapacityForecast,兼容已有 import 路径 './AIPredictiveAnalytics' export type { CapacityForecast }; export function AIPredictiveAnalytics() { - const { t } = useTranslation(); const [selectedTimeRange, setSelectedTimeRange] = useState('24h'); const [isAnalyzing, setIsAnalyzing] = useState(false); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('metrics'); - // 原始数据 const [rawMetrics, setRawMetrics] = useState([]); const [serverStats, setServerStats] = useState({}); - const [alertEvents, setAlertEvents] = useState([]); - const [alertRules, setAlertRules] = useState([]); + const [billingStats, setBillingStats] = useState(null); + const [billingRecords, setBillingRecords] = useState([]); + const [billingAlertRules, setBillingAlertRules] = useState([]); useEffect(() => { void loadData(); @@ -61,18 +60,23 @@ export function AIPredictiveAnalytics() { const loadData = async () => { setLoading(true); try { - const [metricsData, statsResp, alertsResp, rulesResp] = await Promise.all([ + const start = new Date(); + start.setDate(start.getDate() - 89); + const startDate = start.toISOString().slice(0, 10); + + const [metricsData, statsResp, billStats, billList, billRules] = await Promise.all([ dashboardStatsApi.getMetrics(72), serverApi.getServerStats(), - alertApi.getAlerts({ page: 1, page_size: 100 }), - alertApi.getRules(), + billingApi.getStats().catch(() => null), + billingApi.list({ start_date: startDate, page_size: 500 }).catch(() => ({ list: [] })), + billingApi.listAlertRules().catch(() => ({ list: [] })), ]); + setRawMetrics(Array.isArray(metricsData) ? metricsData : []); setServerStats(statsResp || {}); - const alertsRaw = (alertsResp as JsonData)?.list || (alertsResp as JsonData) || []; - setAlertEvents(Array.isArray(alertsRaw) ? alertsRaw : []); - const rulesRaw = (rulesResp as JsonData)?.list || (rulesResp as JsonData) || []; - setAlertRules(Array.isArray(rulesRaw) ? rulesRaw : []); + setBillingStats(billStats); + setBillingRecords((billList as JsonData)?.list || []); + setBillingAlertRules((billRules as JsonData)?.list || []); } catch { // 保持空数据,组件显示空状态 } finally { @@ -86,15 +90,21 @@ export function AIPredictiveAnalytics() { setIsAnalyzing(false); }; - // ============ 综合预测推算(线性回归 + 告警规则阈值 + 事件频率) ============ - const { - predictions, - capacityForecasts, - dedupedAnomalies, - accuracy, - predictedAlertCount, - capacityWarningCount, - } = computePredictions({ rawMetrics, serverStats, alertEvents, alertRules, selectedTimeRange }); + const { predictions, capacityForecasts, accuracy, capacityWarningCount } = computePredictions({ + rawMetrics, + serverStats, + selectedTimeRange, + }); + + const costData = useMemo( + () => + computeCostPredictions({ + billingStats, + billingRecords, + billingAlertRules, + }), + [billingStats, billingRecords, billingAlertRules], + ); const getTrendIcon = (trend: string) => { switch (trend) { @@ -108,16 +118,16 @@ export function AIPredictiveAnalytics() { }; const pageTabs = [ - { id: 'metrics', label: t('aiops.metricPredict') }, - { id: 'capacity', label: t('aiops.predictiveCapacity') }, - { id: 'anomaly', label: t('aiops.alertAnalysisPredict') }, + { id: 'metrics', label: '指标预测' }, + { id: 'capacity', label: '容量规划' }, + { id: 'cost', label: '成本预测' }, ]; return ( } tabs={pageTabs} activeTab={activeTab} @@ -125,10 +135,16 @@ export function AIPredictiveAnalytics() { metrics={( 0 + ? formatCurrency(costData.summary.predictedMonthEnd) + : '—', + color: 'purple', + }, ]} /> )} @@ -140,10 +156,10 @@ export function AIPredictiveAnalytics() { - {t('monitor.traceMgr.hour1')} - {t('monitor.traceMgr.hour24')} - {t('monitor.traceMgr.day7')} - {t('monitor.siteProtocol.ssl.ref30days')} + 1小时 + 24小时 + 7天 + 30天 + +
+ + {cliMode === 'host' ? ( + + ) : ( + <> + + + + + + + + setPodSearch(e.target.value)} + placeholder="搜索 Pod 名称" + className="h-8 text-xs dg-input" + /> +
+ + {filteredPods.map((p) => ( + + ))} + {filteredPods.length === 0 && ( +
无匹配 Pod
+ )} +
+
+
+ + )}
- -
{t('aiops.safetyModeDesc')}
+
安全模式说明

- {t('aiops.safetyModeHint')} + 控制 AI 执行远程命令前是否需要你确认。危险命令(如 rm -rf /)任何模式均硬性拦截。

{SAFETY_MODES.map((m) => (
{m.label} {m.short} {'recommended' in m && m.recommended && ( - {t('common.recommended')} + 推荐 )}

{m.desc}

-

{t('aiops.examplePrefix')}{m.examples}

+

例:{m.examples}

))} @@ -451,40 +637,40 @@ export default function AgentCLI() { size="sm" className="h-8 px-2 text-xs dg-text-secondary" onClick={() => setHistoryOpen((v) => !v)} - title={historyOpen ? t('aiops.collapseHistory') : t('aiops.expandHistory')} + title={historyOpen ? '收起历史任务' : '展开历史任务'} > {historyOpen ? : } - {t('aiops.history')} + 历史
{connected && ( - {t('common.connected')} + 已连接 )} {viewMode && ( - {t('aiops.viewRecord')} + 查看记录 )} {canContinue && !running && !viewMode && ( - {t('aiops.canFollowup')} + 可追问 )} {running && ( - {t('aiops.roundTokens', { round, tokens })} + 第 {round} 轮 · {tokens}t )} - -
{t('aiops.coreAbility')}
- {TIPS.map((t) => ( +
核心能力
+ {currentTips.map((t) => (
@@ -501,7 +687,10 @@ export default function AgentCLI() {
{historyOpen && ( - {t('aiops.historyReadonly', { title: viewSession.title })} + 历史记录(只读)· {viewSession.title} {canResumeView && ( )}
)} @@ -534,23 +723,25 @@ export default function AgentCLI() { {items.length === 0 && (
- {!serverId ? ( + {!wsReady ? ( <> -
{t('aiops.selectOneHostFirst')}
- {servers.length === 0 && ( -
{t('aiops.noHostsHint')}
- )} +
+ {cliMode === 'kubernetes' ? '请先选择集群和命名空间' : '请先选择一台目标主机'} +
) : !connected ? ( <> -
{t('aiops.connectingHost')}
+
{cliMode === 'kubernetes' ? '正在连接集群...' : '正在连接主机...'}
) : (
- {t('aiops.describeTaskHint')} + {cliMode === 'kubernetes' + ? '描述 Kubernetes 排障任务,例如「查看 NotReady 节点」或「分析 nginx Pod 重启原因」' + : '描述你要执行的任务,例如「检查磁盘使用率并清理大文件」'}
- {t('aiops.currentSafetyMode', { label: currentSafety.label, short: currentSafety.short })} + 当前安全模式:{currentSafety.label}({currentSafety.short}) + {cliMode === 'kubernetes' && podName === POD_NONE && ' · 未选 Pod,仅集群/NS 级查询'}
)} @@ -558,30 +749,32 @@ export default function AgentCLI() { )}
- {items.length > 0 && ( + {items.length > 0 && (!scrollAtTop || !scrollAtBottom) && (
- - + {!scrollAtTop && ( + + )} + {!scrollAtBottom && ( + + )}
)}
@@ -593,23 +786,24 @@ export default function AgentCLI() { onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} placeholder={ - viewMode ? t('aiops.viewModeHint') - : canContinue ? t('aiops.followupPlaceholder') - : serverId ? t('aiops.inputPlaceholderAgent') : t('aiops.selectTargetHostFirst') + viewMode ? '查看模式中,点击「继续追问」或「新建任务」' + : canContinue ? '继续追问,回车发送(Shift+回车换行)' + : wsReady ? '输入任务描述,回车发送(Shift+回车换行)' + : (cliMode === 'kubernetes' ? '请先选择集群和命名空间' : '请先选择目标主机') } rows={3} disabled={viewMode} className="w-full resize-none dg-input min-h-[5rem] max-h-40 text-sm leading-relaxed disabled:opacity-50" />
- {t('aiops.shiftEnterHint')} + Shift+回车换行 · 回车发送 {running ? ( ) : ( )}
diff --git a/frontend/src/components/aiops/AgentCLIHistory.tsx b/frontend/src/components/aiops/AgentCLIHistory.tsx index 4b94331..b40c00c 100644 --- a/frontend/src/components/aiops/AgentCLIHistory.tsx +++ b/frontend/src/components/aiops/AgentCLIHistory.tsx @@ -4,14 +4,16 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useConfirm } from '@/components/ui/confirm-dialog'; -import { agentCliApi, type AgentCliSession } from '@/api/aiops/agent-cli'; +import { agentCliApi, type AgentCliMode, type AgentCliSession } from '@/api/aiops/agent-cli'; import { messagesToChatItems, type ChatItem } from './AgentCLIMessages'; import { toast } from 'sonner'; import { History, Trash2, Plus, Loader2, PanelLeftClose } from 'lucide-react'; -import i18n from '@/i18n'; interface Props { - serverId: string; + mode: AgentCliMode; + serverId?: string; + clusterId?: string; + namespace?: string; onView: (items: ChatItem[], session: AgentCliSession) => void; onNewTask: () => void; refreshKey?: number; @@ -28,31 +30,38 @@ function statusBadge(status: string) { } } -export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKey, onCollapse }: Props) { +export default function AgentCLIHistory({ mode, serverId, clusterId, namespace, onView, onNewTask, refreshKey, onCollapse }: Props) { const confirm = useConfirm(); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(false); const [activeId, setActiveId] = useState(null); + const contextReady = mode === 'kubernetes' + ? Boolean(clusterId && namespace) + : Boolean(serverId); + const loadSessions = useCallback(async () => { - if (!serverId) { + if (!contextReady) { setSessions([]); return; } setLoading(true); try { const res = await agentCliApi.getSessions({ - server_id: Number(serverId), + mode, + server_id: mode === 'host' && serverId ? Number(serverId) : undefined, + cluster_id: mode === 'kubernetes' && clusterId ? Number(clusterId) : undefined, + namespace: mode === 'kubernetes' ? namespace : undefined, page: 1, page_size: 50, }); setSessions(res?.list || []); } catch { - toast.error(i18n.t('aiops.agentclihistory.s119e8948')); + toast.error('加载历史会话失败'); } finally { setLoading(false); } - }, [serverId]); + }, [contextReady, mode, serverId, clusterId, namespace]); useEffect(() => { loadSessions(); }, [loadSessions, refreshKey]); @@ -63,17 +72,17 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe setActiveId(sess.id); onView(items, sess); } catch { - toast.error(i18n.t('aiops.agentclihistory.sa4671c05')); + toast.error('加载会话详情失败'); } }; const handleDelete = async (e: React.MouseEvent, sess: AgentCliSession) => { e.stopPropagation(); const ok = await confirm({ - title: i18n.t('audit.terminal.deleteTitle'), - description: i18n.t('aiops.confirmDeleteSession', { title: sess.title }), - confirmText: i18n.t('kubernetes.storage.delete2'), - cancelText: i18n.t('rbac.jobRole.cancel'), + title: '删除会话', + description: `确定删除「${sess.title}」?此操作不可恢复。`, + confirmText: '删除', + cancelText: '取消', variant: 'destructive', }); if (!ok) return; @@ -84,9 +93,9 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe onNewTask(); } loadSessions(); - toast.success(i18n.t('monitor.silenceRules.deleted')); + toast.success('已删除'); } catch { - toast.error(i18n.t('kubernetes.msg.deleteFailed2')); + toast.error('删除失败'); } }; @@ -94,23 +103,25 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe
- {i18n.t('k8s.history.historical')} + 历史 {onCollapse && ( - )}
- {!serverId ? ( -
{i18n.t('aiops.agentclihistory.s414f8d5b')}
+ {!contextReady ? ( +
+ {mode === 'kubernetes' ? '选择集群和命名空间后查看' : '选择主机后查看'} +
) : loading ? (
) : sessions.length === 0 ? ( -
{i18n.t('aiops.agentclihistory.s89fe87e5')}
+
暂无历史
) : (
{sessions.map((s) => ( @@ -118,7 +129,7 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe key={s.id} type="button" onClick={() => handleView(s)} - title={i18n.t('aiops.agentclihistory.se585e8a2')} + title="查看历史记录" className={`w-full text-left rounded-md px-2 py-1.5 transition-colors group ${ activeId === s.id ? 'bg-blue-500/15 border border-blue-500/30' : 'hover:bg-[var(--dg-bg-hover)]/60 border border-transparent' }`} @@ -127,11 +138,11 @@ export default function AgentCLIHistory({ serverId, onView, onNewTask, refreshKe
{s.title}
- {i18n.t('aiops.roundsTokensSummary', { rounds: s.rounds, tokens: s.total_tokens || 0 })} + {s.rounds}轮 · {s.total_tokens || 0}t
- {s.status === 'done' ? i18n.t('common.finish') : s.status === 'running' ? i18n.t('aiops.agentclihistory.s5fb0d006') : s.status === 'aborted' ? i18n.t('aiops.agentclihistory.sd8d7ca77') : i18n.t('system.loginFailed')} + {s.status === 'done' ? '完成' : s.status === 'running' ? '进行' : s.status === 'aborted' ? '中断' : '失败'} )}
@@ -63,10 +63,10 @@ export function PredictiveCapacityTab({ {loading ? (
- {i18n.t('aiops.loadingCapacityData')} + 加载容量数据...
) : forecasts.length === 0 ? ( -
{i18n.t('aiops.predictivecapac.sf803eb75')}
+
暂无容量数据
) : (
{/* AI 深度解读结果(点击按钮后展示) */} @@ -74,9 +74,13 @@ export function PredictiveCapacityTab({
- {i18n.t('aiops.aiDeepAnalysis')} + AI 深度解读
-

{insight}

+ {looksLikeMarkdown(insight) ? ( + + ) : ( +

{insight}

+ )}
)} {forecasts.map((forecast, idx) => ( @@ -97,22 +101,24 @@ export function PredictiveCapacityTab({ } > {forecast.daysUntilThreshold <= 7 - ? i18n.t('aiops.predictivecapac.scb0ed5ef') + ? '紧急' : forecast.daysUntilThreshold <= 14 - ? i18n.t('kubernetes.event.warning') - : i18n.t('agent.heartbeatNormal')} + ? '警告' + : '正常'}
- {i18n.t('aiops.sevenDayForecast')} + 7天使用率预测 {forecast.daysUntilThreshold >= 999 ? ( - i18n.t('aiops.predictutilsts.s179fd5a9') + '增长平缓,暂无触顶风险' ) : ( <> - {i18n.t('aiops.expectedReachThreshold', { days: forecast.daysUntilThreshold })} + 预计{' '} + {forecast.daysUntilThreshold}天{' '} + 后达到阈值 )} @@ -129,9 +135,9 @@ export function PredictiveCapacityTab({
- {i18n.t('aiops.currentUsageLabel', { n: forecast.currentUsage })} - {i18n.t('aiops.predictedUsageLabel', { n: forecast.predictedUsage })} - {i18n.t('aiops.thresholdLabel', { n: forecast.threshold })} + 当前 {forecast.currentUsage}% + 预测 {forecast.predictedUsage}% + 阈值 {forecast.threshold}%
@@ -139,7 +145,7 @@ export function PredictiveCapacityTab({
- {i18n.t('aiops.aiSuggestionColon')} + AI建议:

{forecast.recommendation}

diff --git a/frontend/src/components/aiops/PredictiveCostTab.tsx b/frontend/src/components/aiops/PredictiveCostTab.tsx new file mode 100644 index 0000000..702decf --- /dev/null +++ b/frontend/src/components/aiops/PredictiveCostTab.tsx @@ -0,0 +1,346 @@ +import { useState } from 'react'; +import { + Wallet, + TrendingUp, + TrendingDown, + Minus, + Loader2, + Sparkles, + Zap, + AlertTriangle, +} from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { toast } from 'sonner'; +import { aiApi } from '@/api'; +import { + buildCostInsightPrompt, + trendColorClass, + type ComputedCostPredictions, +} from './predictUtils'; +import { + formatCurrency, + TrendChart, + providerLabels, + ProviderDot, +} from '@/components/cloud/CloudBillingHelpers'; +import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'; + +export function PredictiveCostTab({ + costData, + loading, +}: { + costData: ComputedCostPredictions; + loading: boolean; +}) { + const [insight, setInsight] = useState(''); + const [analyzing, setAnalyzing] = useState(false); + const { summary, dimensionForecasts, budgetRisks, billingPattern, recentDailyAvg } = costData; + const hasData = summary.monthCost > 0 || summary.monthTrend.length > 0; + + const getTrendIcon = (trend: string) => { + switch (trend) { + case 'up': + return ; + case 'down': + return ; + default: + return ; + } + }; + + const analyze = async () => { + const prompt = buildCostInsightPrompt(costData); + if (!prompt) return; + setAnalyzing(true); + try { + const res = await aiApi.chat([{ role: 'user', content: prompt }]); + setInsight(res?.content || ''); + } catch { + toast.error('AI 解读失败,请检查大模型配置或稍后重试'); + } finally { + setAnalyzing(false); + } + }; + + return ( +
+ + +
+ + + 云成本预测总览 + + {!loading && hasData && ( + + )} +
+
+ + {loading ? ( +
+ 加载账单数据... +
+ ) : !hasData ? ( +
+ 暂无账单数据,请先在云账单管理中录入费用记录 +
+ ) : ( +
+ {insight && ( +
+
+ + AI 深度解读 +
+ {looksLikeMarkdown(insight) ? ( + + ) : ( +

+ {insight} +

+ )} +
+ )} + + {billingPattern === 'front_loaded' && ( +
+ + + 账单呈月初集中、近7日日均 {formatCurrency(recentDailyAvg)},可能含预付费或资源已停用;预测已按近期运行费率外推。 + +
+ )} + +
+
+

本月已花

+

+ {formatCurrency(summary.monthCost)} +

+
+
+

预测月末

+

+ {formatCurrency(summary.predictedMonthEnd)} +

+
+
+

日均费用

+

+ {formatCurrency(summary.dailyAvg)} +

+
+
+

距月末

+

+ {summary.daysRemaining} 天 +

+
+ {getTrendIcon(summary.trend)} + + {summary.trend === 'up' ? '上升' : summary.trend === 'down' ? '下降' : '平稳'} + + 置信 {summary.confidence}% +
+
+
+ +
+

本月日费用趋势

+ +
+
+ )} +
+
+ + {!loading && dimensionForecasts.length > 0 && ( + + + 分维度成本预测 + + + {dimensionForecasts.map((dim, idx) => ( +
+
+
+ {dim.provider && } + + {dim.dimension} + + {dim.provider && ( + + {providerLabels[dim.provider] || dim.provider} + + )} +
+
+ {getTrendIcon(dim.trend)} + + {dim.trend === 'up' ? '上升' : dim.trend === 'down' ? '下降' : '平稳'} + +
+
+
+
+

本月累计

+

+ {formatCurrency(dim.currentMonth)} +

+
+
+

预测月末

+

+ {formatCurrency(dim.predictedMonthEnd)} +

+
+
+

日均

+

+ {formatCurrency(dim.dailyAvg)} +

+
+
+
+
+ +

{dim.recommendation}

+
+
+
+ ))} +
+
+ )} + + {!loading && ( + + +
+ + + 预算触线风险 + + 基于账单预警规则与费用趋势推算 +
+
+ + {budgetRisks.length === 0 ? ( +
+ 暂无预算风险,可在云账单管理中配置预警规则 +
+ ) : ( +
+ {budgetRisks.map((risk, idx) => ( +
+
+
+
+

{risk.ruleName}

+ + {risk.provider} + + = 70 + ? 'border-red-500/30 dg-text-red bg-red-500/10' + : risk.probability >= 50 + ? 'border-amber-500/30 dg-text-yellow bg-amber-500/10' + : 'border-blue-500/30 dg-text-blue bg-blue-500/10' + } + > + {risk.metric === 'daily' ? '日预算' : '月预算'} + +
+

+ 当前 {formatCurrency(risk.current)} / 阈值 {formatCurrency(risk.threshold)} + ,预测 {formatCurrency(risk.predicted)} +

+
+ {risk.timeframe} + + 触线概率{' '} + = 70 ? 'dg-text-red' : 'dg-text-yellow' + } + > + {risk.probability}% + + +
+
+
+ + + = 70 + ? 'dg-text-red' + : risk.probability >= 50 + ? 'dg-text-yellow' + : 'dg-text-blue' + } + /> + +
+ + {risk.probability}% + +
+
+
+
+
+ +

{risk.suggestedAction}

+
+
+
+ ))} +
+ )} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/aiops/predictUtils.ts b/frontend/src/components/aiops/predictUtils.ts index a9b5e92..c19d992 100644 --- a/frontend/src/components/aiops/predictUtils.ts +++ b/frontend/src/components/aiops/predictUtils.ts @@ -1,5 +1,4 @@ import type { JsonData } from "@/types/common"; -import i18n from '@/i18n'; /** * AI 预测分析模块的纯计算工具 — 类型定义、常量、线性回归、预测推算 * @@ -30,28 +29,78 @@ export interface CapacityForecast { recommendation: string; } -export interface AnomalyPrediction { - metric: string; - predictedAnomaly: string; +/** 日费用数据点 */ +export interface DailyCostPoint { + date: string; + cost: number; +} + +/** 单维度(全部 / 云厂商)成本预测 */ +export interface CostDimensionForecast { + dimension: string; + provider?: string; + currentMonth: number; + predictedMonthEnd: number; + dailyAvg: number; + trend: 'up' | 'down' | 'stable'; + confidence: number; + recommendation: string; +} + +/** 预算触线风险(对接 BillingAlertRule) */ +export interface BudgetRiskForecast { + ruleName: string; + provider: string; + metric: 'daily' | 'monthly'; + threshold: number; + current: number; + predicted: number; probability: number; timeframe: string; suggestedAction: string; } +/** 成本预测汇总 */ +export interface CostPredictionSummary { + monthCost: number; + predictedMonthEnd: number; + todayCost: number; + yesterdayCost: number; + dailyAvg: number; + daysRemaining: number; + trend: 'up' | 'down' | 'stable'; + confidence: number; + monthTrend: DailyCostPoint[]; + providerDist: { provider: string; cost: number; percent: number }[]; +} + +export interface ComputedCostPredictions { + summary: CostPredictionSummary; + dimensionForecasts: CostDimensionForecast[]; + budgetRisks: BudgetRiskForecast[]; + budgetRiskCount: number; + /** 近 7 日日均(用于识别预付费后按量趋零) // Logan.Li */ + recentDailyAvg: number; + /** 账单形态:steady 平稳 / front_loaded 月初集中 / sparse 样本不足 */ + billingPattern: 'steady' | 'front_loaded' | 'sparse'; + /** 本月 Top 服务消费 */ + topServices: { service: string; cost: number }[]; +} + // ============ 常量 ============ /** 指标定义:后端 metric name → 展示标签 / 告警表达式前缀 / 阈值 */ export const METRIC_DEFS = [ - { key: 'cpu', label: i18n.t('aiops.predictutilsts.s7054bc34'), exprKey: 'cpu_usage', threshold: 85 }, - { key: 'memory', label: i18n.t('kubernetes.cluster.memoryUsage'), exprKey: 'memory_usage', threshold: 85 }, - { key: 'disk', label: i18n.t('monitor.hostDisk'), exprKey: 'disk_usage', threshold: 90 }, + { key: 'cpu', label: 'CPU使用率', exprKey: 'cpu_usage', threshold: 85 }, + { key: 'memory', label: '内存使用率', exprKey: 'memory_usage', threshold: 85 }, + { key: 'disk', label: '磁盘使用率', exprKey: 'disk_usage', threshold: 90 }, ] as const; export const TIME_RANGE_LABELS: Record = { - '1h': i18n.t('aiops.predictutilsts.s75719bb8'), - '24h': i18n.t('aiops.predictutilsts.sfc78646e'), - '7d': i18n.t('aiops.predictutilsts.s5b5a820c'), - '30d': i18n.t('aiops.predictutilsts.s933ab0a6'), + '1h': '1小时后', + '24h': '24小时后', + '7d': '7天后', + '30d': '30天后', }; export const TIME_RANGE_HOURS: Record = { @@ -97,45 +146,27 @@ export function extractSeries( metrics: JsonData[], name: string): number[] { .map((m) => Number(m.value) || 0); } -/** 从告警表达式中解析阈值,如 "cpu_usage > 80" → 80 */ -export function parseThreshold(expr: string): number | null { - const m = expr.match(/[><=]\s*([\d.]+)/); - return m ? parseFloat(m[1]) : null; -} - /** 容量规划建议 */ export function capacityRecommendation(metricKey: string, days: number): string { const level = days <= 7 ? 'urgent' : days <= 14 ? 'warning' : 'normal'; const recs: Record> = { urgent: { - cpu: i18n.t('aiops.predictutilsts.s4d0bd643'), - memory: i18n.t('aiops.predictutilsts.sb4593f3a'), - disk: i18n.t('aiops.predictutilsts.s6c25383f'), + cpu: 'CPU增长趋势明显,建议尽快扩容计算资源或优化高负载进程', + memory: '内存增长趋势明显,建议增加内存或排查内存泄漏', + disk: '磁盘空间即将耗尽,建议立即扩容或清理历史数据与日志', }, warning: { - cpu: i18n.t('aiops.predictutilsts.s8ea826f4'), - memory: i18n.t('aiops.predictutilsts.sb08bd5fc'), - disk: i18n.t('aiops.predictutilsts.s4cdda5dc'), + cpu: 'CPU使用率呈上升趋势,建议持续监控并准备扩容计划', + memory: '内存使用率呈上升趋势,建议优化应用内存使用', + disk: '磁盘使用率持续增长,建议规划数据归档和清理策略', }, normal: { - cpu: i18n.t('aiops.predictutilsts.sfa295c44'), - memory: i18n.t('aiops.predictutilsts.s7156a4c0'), - disk: i18n.t('aiops.predictutilsts.s0073f3a3'), + cpu: 'CPU使用正常,建议持续监控', + memory: '内存使用正常,建议持续监控', + disk: '磁盘空间充足,建议持续监控', }, }; - return recs[level]?.[metricKey] || i18n.t('aiops.predictutilsts.sfeb368e6'); -} - -/** 异常建议操作 */ -export function anomalyAction(metricKey: string): string { - const actions: Record = { - cpu: i18n.t('aiops.predictutilsts.sdf4c1339'), - memory: i18n.t('aiops.predictutilsts.sdf7d1c13'), - disk: i18n.t('aiops.predictutilsts.sb0436cd9'), - service: i18n.t('aiops.predictutilsts.s71b29ff9'), - pod: i18n.t('aiops.predictutilsts.s0c1a3cd3'), - }; - return actions[metricKey] || i18n.t('aiops.predictutilsts.se5bd8880'); + return recs[level]?.[metricKey] || '建议持续监控资源使用趋势'; } // ============ 综合预测推算 ============ @@ -144,8 +175,6 @@ export function anomalyAction(metricKey: string): string { export interface ComputePredictionsInput { rawMetrics: JsonData[]; serverStats?: JsonData; - alertEvents: JsonData[]; - alertRules: JsonData[]; selectedTimeRange: string; // '1h' | '24h' | '7d' | '30d' } @@ -153,21 +182,19 @@ export interface ComputePredictionsInput { export interface ComputedPredictions { predictions: PredictionMetric[]; capacityForecasts: CapacityForecast[]; - dedupedAnomalies: AnomalyPrediction[]; accuracy: number; - predictedAlertCount: number; capacityWarningCount: number; } /** - * 综合预测推算:基于时序指标 + 告警规则,计算指标预测、容量规划、异常预测。 - * 主组件 AIPredictiveAnalytics.tsx 直接调用此函数获取所有展示数据。 + * 综合预测推算:基于时序指标计算指标预测与容量规划。 + * 主组件 AIPredictiveAnalytics.tsx 直接调用此函数获取展示数据。 * // Logan.Li */ export function computePredictions(input: ComputePredictionsInput): ComputedPredictions { - const { rawMetrics, serverStats, alertEvents, alertRules, selectedTimeRange } = input; + const { rawMetrics, serverStats, selectedTimeRange } = input; const horizonHours = TIME_RANGE_HOURS[selectedTimeRange] || 24; - const timeRangeLabel = TIME_RANGE_LABELS[selectedTimeRange] || i18n.t('aiops.predictutilsts.sfc78646e'); + const timeRangeLabel = TIME_RANGE_LABELS[selectedTimeRange] || '24小时后'; // ---- 指标预测 ---- const predictions: PredictionMetric[] = METRIC_DEFS.map((def) => { @@ -207,9 +234,9 @@ export function computePredictions(input: ComputePredictionsInput): ComputedPred daysUntilThreshold = daysUntilThreshold < 1 ? 1 : daysUntilThreshold; } const resourceNames: Record = { - cpu: i18n.t('aiops.predictutilsts.se67014cc'), - memory: i18n.t('aiops.predictutilsts.s2050241a'), - disk: i18n.t('aiops.predictutilsts.sf9724444'), + cpu: '服务器CPU算力', + memory: '服务器内存', + disk: '存储磁盘空间', }; return { resource: resourceNames[def.key] || def.label, @@ -222,89 +249,18 @@ export function computePredictions(input: ComputePredictionsInput): ComputedPred }; }).filter((f) => f.currentUsage > 0); - // ---- 异常预测(基于告警规则 + 趋势 + 事件频率) ---- - const anomalyPredictions: AnomalyPrediction[] = []; - - for (const rule of alertRules) { - if (!rule.enabled) continue; - const threshold = parseThreshold(rule.expression || ''); - const exprLower = (rule.expression || '').toLowerCase(); - const matchedDef = METRIC_DEFS.find((def) => exprLower.includes(def.exprKey)); - - if (matchedDef && threshold) { - const series = extractSeries(rawMetrics, matchedDef.key); - const { slope } = linearRegression(series); - const avgFallback = - matchedDef.key === 'cpu' - ? serverStats.avg_cpu - : matchedDef.key === 'memory' - ? serverStats.avg_memory - : serverStats.avg_disk; - const current = series.length > 0 ? series[series.length - 1] : avgFallback || 0; - const ratio = threshold > 0 ? current / threshold : 0; - let probability = Math.round(ratio * 55); - if (slope > 0.05) probability += 15; - const eventCount = alertEvents.filter( - (a) => a.rule_id === rule.id || a.rule_name === rule.name, - ).length; - probability += Math.min(20, eventCount * 3); - probability = Math.max(5, Math.min(90, probability)); - - // 时间范围:基于斜率估算何时触及阈值 - let timeframe = i18n.t('aiops.next24Hours'); - if (slope > 0.01 && threshold > current) { - const hours = (threshold - current) / slope; - if (hours < 4) timeframe = i18n.t('aiops.next4Hours'); - else if (hours < 12) timeframe = i18n.t('aiops.next12Hours'); - else if (hours < 24) timeframe = i18n.t('aiops.next24Hours'); - else timeframe = i18n.t('aiops.nextNDays', { n: Math.ceil(hours / 24) }); - } - - anomalyPredictions.push({ - metric: rule.name, - predictedAnomaly: rule.description || i18n.t('aiops.mayTrigger', { name: rule.name }), - probability, - timeframe, - suggestedAction: anomalyAction(matchedDef.key), - }); - } else { - // 非 metric 规则(如服务不可用、Pod重启)基于事件频率 - const eventCount = alertEvents.filter( - (a) => a.rule_id === rule.id || a.rule_name === rule.name, - ).length; - if (eventCount > 0) { - const exprKey = exprLower.includes('service') ? 'service' : exprLower.includes('pod') ? 'pod' : 'service'; - anomalyPredictions.push({ - metric: rule.name, - predictedAnomaly: rule.description || i18n.t('aiops.mayRecur', { name: rule.name }), - probability: Math.min(60, 20 + eventCount * 5), - timeframe: i18n.t('aiops.predictutilsts.s658d6e80'), - suggestedAction: anomalyAction(exprKey), - }); - } - } - } - - // 去重:同一指标名只保留概率最高的一条 - const dedupedAnomalies = anomalyPredictions - .sort((a, b) => b.probability - a.probability) - .filter((item, idx, arr) => arr.findIndex((x) => x.metric === item.metric) === idx); - // ---- 概览统计 ---- const avgR2 = METRIC_DEFS.length > 0 ? METRIC_DEFS.reduce((sum, def) => sum + linearRegression(extractSeries(rawMetrics, def.key)).r2, 0) / METRIC_DEFS.length : 0; const accuracy = Math.round(78 + avgR2 * 17); - const predictedAlertCount = dedupedAnomalies.filter((a) => a.probability >= 50).length; const capacityWarningCount = capacityForecasts.filter((f) => f.daysUntilThreshold <= 14).length; return { predictions, capacityForecasts, - dedupedAnomalies, accuracy, - predictedAlertCount, capacityWarningCount, }; } @@ -328,18 +284,432 @@ export function trendColorClass(trend: string): string { export function buildCapacityInsightPrompt(forecasts: CapacityForecast[]): string { if (forecasts.length === 0) return ''; const lines = forecasts.map((f) => { - const risk = f.daysUntilThreshold >= 999 ? i18n.t('aiops.predictutilsts.s179fd5a9') : i18n.t('aiops.daysToThreshold', { days: f.daysUntilThreshold }); - return i18n.t('aiops.forecastSummary', { resource: f.resource, current: f.currentUsage, predicted: f.predictedUsage, threshold: f.threshold, risk }); + const risk = f.daysUntilThreshold >= 999 ? '增长平缓,暂无触顶风险' : `${f.daysUntilThreshold} 天后触及阈值`; + return `- ${f.resource}:当前 ${f.currentUsage}% → 7天后预测 ${f.predictedUsage}%(阈值 ${f.threshold}%),${risk}`; }); return [ - i18n.t('aiops.predictutilsts.s3b46e5a0'), + '你是 AIOps 容量规划专家。基于以下服务器资源容量预测数据,给出综合风险评估与可执行的运维建议:', '', - i18n.t('aiops.predictutilsts.s586bac5d'), + '【容量预测数据】', ...lines, '', - i18n.t('aiops.predictutilsts.sdf6ebee3'), - i18n.t('aiops.predictutilsts.s534b6893'), - i18n.t('aiops.predictutilsts.saf173e7a'), - i18n.t('aiops.predictutilsts.s4ed1d035'), + '要求:', + '1. 指出最紧急的风险资源(触及阈值最快者优先)', + '2. 给出针对性处置建议(扩容 / 清理 / 优化,具体到操作命令或步骤)', + '3. Markdown 输出,用 **加粗小标题** 分段(如 **紧急风险**、**处置建议**),每段 1~2 条要点,总字数 300 字以内', + ].join('\n'); +} + +// ============ 云成本预测 ============ + +const PROVIDER_LABELS: Record = { + aliyun: '阿里云', + tencent: '腾讯云', + aws: 'AWS', + huawei: '华为云', + azure: 'Azure', +}; + +/** 本月第一天 YYYY-MM-DD */ +export function monthStartDate(ref = new Date()): string { + return `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`; +} + +/** 本月剩余天数(不含今天) */ +export function daysRemainingInMonth(ref = new Date()): number { + const lastDay = new Date(ref.getFullYear(), ref.getMonth() + 1, 0).getDate(); + return Math.max(0, lastDay - ref.getDate()); +} + +/** 账单明细按日聚合 */ +export function aggregateDailyCosts( + records: JsonData[], + filter?: { startDate?: string; provider?: string }, +): DailyCostPoint[] { + const map = new Map(); + for (const r of records) { + const date = String(r.billing_date || ''); + if (!date) continue; + if (filter?.startDate && date < filter.startDate) continue; + if (filter?.provider && r.provider !== filter.provider) continue; + map.set(date, (map.get(date) || 0) + (Number(r.cost) || 0)); + } + return Array.from(map.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([date, cost]) => ({ date, cost })); +} + +/** 本月累计费用 */ +export function sumMonthCost(records: JsonData[], provider?: string): number { + const start = monthStartDate(); + return records.reduce((sum, r) => { + const date = String(r.billing_date || ''); + if (date < start) return sum; + if (provider && r.provider !== provider) return sum; + return sum + (Number(r.cost) || 0); + }, 0); +} + +function costTrendFromSlope(slope: number): 'up' | 'down' | 'stable' { + if (slope > 1) return 'up'; + if (slope < -1) return 'down'; + return 'stable'; +} + +/** 成本优化建议 */ +export function costRecommendation(trend: 'up' | 'down' | 'stable', ratio: number): string { + if (trend === 'up' && ratio >= 0.85) { + return '费用增长较快且接近预算,建议排查高消费服务、清理闲置资源并优化 CDN/存储计费'; + } + if (trend === 'up') { + return '费用呈上升趋势,建议按云厂商与服务维度复盘账单,锁定异常增长项'; + } + if (trend === 'down') { + return '费用呈下降趋势,可维持当前优化策略并持续监控'; + } + return '费用整体平稳,建议设置月度预算预警并定期核对账单'; +} + +/** 预算风险建议 */ +function budgetRiskAction(metric: 'daily' | 'monthly', provider: string): string { + const scope = provider && provider !== '全部' ? `${provider} ` : ''; + if (metric === 'daily') { + return `检查${scope}今日高消费服务,暂停非必要资源或调整弹性伸缩策略`; + } + return `复盘${scope}本月账单构成,优化存储/CDN/计算规格,必要时下调预算外资源配额`; +} + +interface MonthEndForecast { + currentMonth: number; + predictedMonthEnd: number; + dailyAvg: number; + recentDailyAvg: number; + billingPattern: 'steady' | 'front_loaded' | 'sparse'; + trend: 'up' | 'down' | 'stable'; + confidence: number; + series: DailyCostPoint[]; +} + +/** 识别账单形态(预付费常表现为月初大额、后续近零) // Logan.Li */ +function detectBillingPattern(series: DailyCostPoint[]): 'steady' | 'front_loaded' | 'sparse' { + const active = series.filter((d) => d.cost > 0); + if (active.length < 2) return 'sparse'; + const total = series.reduce((s, d) => s + d.cost, 0); + const recent = series.slice(-Math.min(7, series.length)); + const recentAvg = recent.reduce((s, d) => s + d.cost, 0) / recent.length; + const overallAvg = total / series.length; + if (overallAvg > 0 && recentAvg < overallAvg * 0.25) return 'front_loaded'; + return 'steady'; +} + +function recentRunRate(series: DailyCostPoint[], days = 7): number { + const recent = series.slice(-Math.min(days, series.length)); + if (recent.length === 0) return 0; + return recent.reduce((s, d) => s + d.cost, 0) / recent.length; +} + +/** 本月 Top 服务消费 */ +function topServicesByCost(records: JsonData[], limit = 5): { service: string; cost: number }[] { + const start = monthStartDate(); + const map = new Map(); + records.forEach((r) => { + const date = String(r.billing_date || ''); + if (date < start) return; + const svc = String(r.service || '其他'); + map.set(svc, (map.get(svc) || 0) + (Number(r.cost) || 0)); + }); + return [...map.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([service, cost]) => ({ service, cost: Math.round(cost * 100) / 100 })); +} + +/** 基于日费用序列预测月末总费用 */ +function forecastMonthEnd( + records: JsonData[], + statsMonthCost: number, + fallbackTrend: DailyCostPoint[], + provider?: string, +): MonthEndForecast { + const start = monthStartDate(); + const monthSeries = aggregateDailyCosts(records, { startDate: start, provider }); + const series = monthSeries.length >= 2 ? monthSeries : fallbackTrend; + const costs = series.map((d) => d.cost); + const { slope, r2 } = linearRegression(costs); + const currentMonth = + monthSeries.length > 0 ? monthSeries.reduce((s, d) => s + d.cost, 0) : statsMonthCost; + const daysLeft = daysRemainingInMonth(); + const dailyAvg = costs.length > 0 ? costs.reduce((a, b) => a + b, 0) / costs.length : 0; + const recentDailyAvg = recentRunRate(series); + const billingPattern = detectBillingPattern(series); + // 预付费/前高后平:用近 7 日运行费率外推,避免线性回归高估或矛盾 // Logan.Li + const extrapolation = + billingPattern === 'front_loaded' ? recentDailyAvg * daysLeft : slope * daysLeft; + const predictedMonthEnd = Math.max(0, Math.round((currentMonth + extrapolation) * 100) / 100); + let confidence = Math.round(55 + r2 * 40); + if (billingPattern === 'front_loaded') confidence = Math.max(45, confidence - 15); + if (billingPattern === 'sparse') confidence = Math.min(confidence, 50); + return { + currentMonth, + predictedMonthEnd, + dailyAvg: Math.round(dailyAvg * 100) / 100, + recentDailyAvg: Math.round(recentDailyAvg * 100) / 100, + billingPattern, + trend: costTrendFromSlope(billingPattern === 'front_loaded' ? recentDailyAvg - dailyAvg : slope), + confidence, + series: monthSeries.length > 0 ? monthSeries : fallbackTrend, + }; +} + +/** computeCostPredictions 输入 */ +export interface ComputeCostPredictionsInput { + billingStats: JsonData | null; + billingRecords: JsonData[]; + billingAlertRules: JsonData[]; +} + +/** + * 云成本预测:基于账单日粒度时序 + 预算预警规则,推算月末费用与触线风险。 + * // Logan.Li + */ +export function computeCostPredictions(input: ComputeCostPredictionsInput): ComputedCostPredictions { + const { billingStats, billingRecords, billingAlertRules } = input; + const empty: ComputedCostPredictions = { + summary: { + monthCost: 0, + predictedMonthEnd: 0, + todayCost: 0, + yesterdayCost: 0, + dailyAvg: 0, + daysRemaining: daysRemainingInMonth(), + trend: 'stable', + confidence: 0, + monthTrend: [], + providerDist: [], + }, + dimensionForecasts: [], + budgetRisks: [], + budgetRiskCount: 0, + recentDailyAvg: 0, + billingPattern: 'sparse', + topServices: [], + }; + + if (!billingStats) return empty; + + const monthTrend: DailyCostPoint[] = Array.isArray(billingStats.month_trend) + ? billingStats.month_trend.map((d: JsonData) => ({ + date: String(d.date || ''), + cost: Number(d.cost) || 0, + })) + : []; + const providerDist: { provider: string; cost: number; percent: number }[] = Array.isArray( + billingStats.provider_dist, + ) + ? billingStats.provider_dist.map((p: JsonData) => ({ + provider: String(p.provider || ''), + cost: Number(p.cost) || 0, + percent: Number(p.percent) || 0, + })) + : []; + + const overall = forecastMonthEnd( + billingRecords, + Number(billingStats.month_cost) || 0, + monthTrend, + ); + + const summary: CostPredictionSummary = { + monthCost: Number(billingStats.month_cost) || overall.currentMonth, + predictedMonthEnd: overall.predictedMonthEnd, + todayCost: Number(billingStats.today_cost) || 0, + yesterdayCost: Number(billingStats.yesterday_cost) || 0, + dailyAvg: overall.dailyAvg, + daysRemaining: daysRemainingInMonth(), + trend: overall.trend, + confidence: overall.confidence, + monthTrend: overall.series.length > 0 ? overall.series : monthTrend, + providerDist, + }; + + // 全部 + 各云厂商维度预测 + const dimensionForecasts: CostDimensionForecast[] = []; + const allRatio = + summary.predictedMonthEnd > 0 ? summary.monthCost / summary.predictedMonthEnd : 0; + dimensionForecasts.push({ + dimension: '全部云账号', + currentMonth: summary.monthCost, + predictedMonthEnd: summary.predictedMonthEnd, + dailyAvg: summary.dailyAvg, + trend: summary.trend, + confidence: summary.confidence, + recommendation: costRecommendation(summary.trend, allRatio), + }); + + const providers = new Set(); + providerDist.forEach((p) => providers.add(p.provider)); + billingRecords.forEach((r) => { + if (r.provider) providers.add(String(r.provider)); + }); + + for (const provider of providers) { + if (!provider) continue; + const provForecast = forecastMonthEnd(billingRecords, 0, monthTrend, provider); + const provMonth = + provForecast.currentMonth > 0 ? provForecast.currentMonth : sumMonthCost(billingRecords, provider); + const ratio = provForecast.predictedMonthEnd > 0 ? provMonth / provForecast.predictedMonthEnd : 0; + dimensionForecasts.push({ + dimension: PROVIDER_LABELS[provider] || provider, + provider, + currentMonth: provMonth, + predictedMonthEnd: provForecast.predictedMonthEnd, + dailyAvg: provForecast.dailyAvg, + trend: provForecast.trend, + confidence: provForecast.confidence, + recommendation: costRecommendation(provForecast.trend, ratio), + }); + } + + dimensionForecasts.sort((a, b) => b.predictedMonthEnd - a.predictedMonthEnd); + + // 预算触线风险 + const budgetRisks: BudgetRiskForecast[] = []; + const activeAlerts: JsonData[] = Array.isArray(billingStats.alerts) ? billingStats.alerts : []; + + for (const rule of billingAlertRules) { + if (!rule.enabled) continue; + const threshold = Number(rule.threshold) || 0; + if (threshold <= 0) continue; + + const metric = (rule.metric === 'daily' ? 'daily' : 'monthly') as 'daily' | 'monthly'; + const ruleProvider = String(rule.provider || ''); + const providerLabel = ruleProvider ? PROVIDER_LABELS[ruleProvider] || ruleProvider : '全部'; + + let current = 0; + let predicted = 0; + let slope = 0; + + if (metric === 'daily') { + current = Number(billingStats.today_cost) || 0; + if (ruleProvider) { + const today = new Date().toISOString().slice(0, 10); + current = billingRecords + .filter((r) => r.billing_date === today && r.provider === ruleProvider) + .reduce((s, r) => s + (Number(r.cost) || 0), 0); + } + const recent = aggregateDailyCosts(billingRecords, ruleProvider ? { provider: ruleProvider } : undefined); + const costs = recent.slice(-7).map((d) => d.cost); + slope = linearRegression(costs).slope; + predicted = Math.max(0, current + slope); + } else { + const provForecast = ruleProvider + ? forecastMonthEnd(billingRecords, 0, monthTrend, ruleProvider) + : overall; + current = ruleProvider ? sumMonthCost(billingRecords, ruleProvider) : summary.monthCost; + predicted = provForecast.predictedMonthEnd; + const costs = provForecast.series.map((d) => d.cost); + slope = linearRegression(costs).slope; + } + + const active = activeAlerts.find((a) => a.rule_id === rule.id || a.rule_name === rule.name); + if (active) current = Number(active.current) || current; + + const ratio = threshold > 0 ? predicted / threshold : 0; + let probability = Math.round(ratio * 50); + if (slope > 0.5) probability += 15; + if (current >= threshold) probability += 25; + probability = Math.max(5, Math.min(92, probability)); + + let timeframe = metric === 'daily' ? '今日' : '本月末'; + if (slope > 0.01 && threshold > current) { + const days = (threshold - current) / slope; + if (days < 1) timeframe = metric === 'daily' ? '今日内' : '1 天内'; + else if (days <= 7) timeframe = `${Math.ceil(days)} 天内`; + else timeframe = `约 ${Math.ceil(days)} 天后`; + } + + if (probability >= 30 || current >= threshold * 0.7) { + budgetRisks.push({ + ruleName: String(rule.name || '预算规则'), + provider: providerLabel, + metric, + threshold, + current: Math.round(current * 100) / 100, + predicted: Math.round(predicted * 100) / 100, + probability, + timeframe, + suggestedAction: budgetRiskAction(metric, providerLabel), + }); + } + } + + budgetRisks.sort((a, b) => b.probability - a.probability); + const budgetRiskCount = budgetRisks.filter((r) => r.probability >= 50).length; + + return { + summary, + dimensionForecasts, + budgetRisks, + budgetRiskCount, + recentDailyAvg: overall.recentDailyAvg, + billingPattern: overall.billingPattern, + topServices: topServicesByCost(billingRecords), + }; +} + +/** + * 构建成本预测「AI 深度解读」prompt。 + * // Logan.Li + */ +export function buildCostInsightPrompt(data: ComputedCostPredictions): string { + const { summary, dimensionForecasts, budgetRisks, recentDailyAvg, billingPattern, topServices } = data; + if (summary.monthCost <= 0 && dimensionForecasts.length === 0) return ''; + + const patternHint = + billingPattern === 'front_loaded' + ? '账单呈月初集中、近期趋零,可能含包年包月预付费或资源已停用,预测已按近7日运行费率外推' + : billingPattern === 'sparse' + ? '日账单样本不足,预测置信度较低' + : '日账单相对平稳,按趋势外推'; + + const dimLines = dimensionForecasts.slice(0, 6).map( + (d) => + `- ${d.dimension}:本月 ${d.currentMonth.toFixed(2)} 元 → 预测月末 ${d.predictedMonthEnd.toFixed(2)} 元(趋势 ${d.trend})`, + ); + const riskLines = + budgetRisks.length > 0 + ? budgetRisks.slice(0, 5).map( + (r) => + `- ${r.ruleName}(${r.provider}):当前 ${r.current} / 阈值 ${r.threshold},预测 ${r.predicted},触线概率 ${r.probability}%`, + ) + : ['- 暂无预算触线风险']; + const serviceLines = + topServices.length > 0 + ? topServices.map((s) => `- ${s.service}:${s.cost.toFixed(2)} 元`) + : ['- 暂无服务维度明细']; + + return [ + '你是 FinOps 云成本优化专家。基于以下云账单预测数据,给出综合成本风险评估与可执行的降本建议:', + '', + '【总览】', + `- 本月已花:${summary.monthCost.toFixed(2)} 元`, + `- 预测月末:${summary.predictedMonthEnd.toFixed(2)} 元(置信 ${summary.confidence}%)`, + `- 全月日均:${summary.dailyAvg.toFixed(2)} 元;近7日日均:${recentDailyAvg.toFixed(2)} 元;剩余 ${summary.daysRemaining} 天`, + `- 账单形态:${patternHint}`, + '', + '【Top 服务消费】', + ...serviceLines, + '', + '【分维度预测】', + ...dimLines, + '', + '【预算风险】', + ...riskLines, + '', + '输出要求(Markdown,中文):', + '1. 用 **加粗小标题** 分段,每段下 1 条要点,例如:**数据疑点**、**闲置资源清理**、**计费模式优化**、**存储生命周期**', + '2. 若全月日均与近7日日均、预测月末存在明显矛盾,必须在「数据疑点」中说明原因(预付费/资源停机等)', + '3. 结合 Top 服务给出可落地建议(EIP/云盘/ECS规格/OSS生命周期/预留实例等)', + '4. 选 3~5 条最相关建议,总字数 350 字以内,不要空泛套话', ].join('\n'); } diff --git a/frontend/src/components/cloud/CloudAccountDetailSheet.tsx b/frontend/src/components/cloud/CloudAccountDetailSheet.tsx new file mode 100644 index 0000000..2b1075a --- /dev/null +++ b/frontend/src/components/cloud/CloudAccountDetailSheet.tsx @@ -0,0 +1,276 @@ +// 云账号详情侧栏 — 概览 / 区域 / 账单 // Logan.Li +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + CheckCircle2, + CloudDownload, + Loader2, + MapPin, + RefreshCw, + Wallet, + XCircle, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { cloudApi, billingApi } from '@/api'; +import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon'; + +import type { JsonData } from '@/types/common'; + +interface CloudAccountDetailSheetProps { + account: JsonData | null; + open: boolean; + onOpenChange: (open: boolean) => void; + providerLabels: Record; + onSyncRegions?: () => void; + onSyncBilling?: () => void; + onImportHosts?: () => void; +} + +export function CloudAccountDetailSheet({ + account, + open, + onOpenChange, + providerLabels, + onSyncRegions, + onSyncBilling, + onImportHosts, +}: CloudAccountDetailSheetProps) { + const { t } = useTranslation(); + const [tab, setTab] = useState('overview'); + const [regions, setRegions] = useState([]); + const [regionsLoading, setRegionsLoading] = useState(false); + const [syncingRegions, setSyncingRegions] = useState(false); + const [billings, setBillings] = useState([]); + const [billingsLoading, setBillingsLoading] = useState(false); + const [syncingBilling, setSyncingBilling] = useState(false); + + const accountId = account?.id as number | string | undefined; + + const loadRegions = useCallback(async () => { + if (!accountId) return; + setRegionsLoading(true); + try { + const res = await cloudApi.getAccountRegions(accountId); + setRegions(res?.list || []); + } catch { + setRegions([]); + } finally { + setRegionsLoading(false); + } + }, [accountId]); + + const loadBillings = useCallback(async () => { + if (!accountId) return; + setBillingsLoading(true); + try { + const monthStart = new Date().toISOString().slice(0, 7) + '-01'; + const res = await billingApi.list({ + cloud_account_id: Number(accountId), + start_date: monthStart, + page_size: 20, + }); + setBillings(res?.list || []); + } catch { + setBillings([]); + } finally { + setBillingsLoading(false); + } + }, [accountId]); + + useEffect(() => { + if (!open || !accountId) return; + setTab('overview'); + loadRegions(); + loadBillings(); + }, [open, accountId, loadRegions, loadBillings]); + + const handleSyncRegions = async () => { + if (!accountId) return; + setSyncingRegions(true); + try { + await cloudApi.syncRegions(accountId); + await loadRegions(); + onSyncRegions?.(); + toast.success(t('cloud.syncRegionsSuccess')); + } catch (e) { + toast.error(t('cloud.syncRegionsFailed'), { description: (e as JsonData).message }); + } finally { + setSyncingRegions(false); + } + }; + + const handleSyncBilling = async () => { + if (!accountId) return; + setSyncingBilling(true); + try { + const res = await cloudApi.syncBilling(accountId); + await loadBillings(); + onSyncBilling?.(); + const errs = res?.errors || []; + if (errs.length > 0) { + toast.warning(t('cloud.syncBillingPartial'), { description: errs.join('\n') }); + } else { + toast.success(t('cloud.syncBillingSuccess'), { + description: t('cloud.syncBillingResult', { + date: res.billing_date, + created: res.created, + updated: res.updated, + }), + }); + } + } catch (e) { + toast.error(t('cloud.syncBillingFailed'), { description: (e as JsonData).message }); + } finally { + setSyncingBilling(false); + } + }; + + if (!account) return null; + + const provider = String(account.provider || ''); + const serverCount = account.server_count ?? account.serverCount ?? 0; + const regionCount = account.region_count ?? account.regionCount ?? regions.length; + const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0; + const lastSync = account.last_sync_at || account.lastSyncAt || account.last_sync; + + return ( + + + +
+
+ +
+
+ {account.name} + + {providerLabels[provider] || provider} + {account.status === 'active' ? ( + + + {t('cloud.normal')} + + ) : account.status === 'inactive' ? ( + + + {t('cloud.stopped')} + + ) : null} + +
+
+
+ + + + {t('cloud.tabOverview')} + {t('cloud.tabRegions')} + {t('cloud.tabBilling')} + + + +
+
+

{t('cloud.monthlyFee')}

+

¥{Number(monthlyCost).toLocaleString()}

+
+
+

{t('cloud.serverCount')}

+

{serverCount}

+
+
+

{t('cloud.regionCount')}

+

{regionCount}

+
+
+ {lastSync && ( +

+ {t('cloud.syncTime')}: {new Date(lastSync).toLocaleString()} +

+ )} +
+ + + {onImportHosts && ( + + )} +
+
+ + +
+ {t('cloud.regionListHint')} + +
+ {regionsLoading ? ( +
+ ) : regions.length === 0 ? ( +

{t('cloud.noRegions')}

+ ) : ( +
+ {regions.map((r) => ( +
+ {r.name || r.code} + {r.code} +
+ ))} +
+ )} +
+ + +
+ {t('cloud.billingMonthHint')} + +
+ {billingsLoading ? ( +
+ ) : billings.length === 0 ? ( +

{t('cloud.noBillingRecords')}

+ ) : ( +
+ {billings.map((b) => ( +
+
+

{b.service || '-'}

+

{b.billing_date} · {b.region || '-'}

+
+ + ¥{Number(b.cost || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + +
+ ))} +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/cloud/CloudAccountManager.tsx b/frontend/src/components/cloud/CloudAccountManager.tsx index 4747a57..ace6d96 100644 --- a/frontend/src/components/cloud/CloudAccountManager.tsx +++ b/frontend/src/components/cloud/CloudAccountManager.tsx @@ -24,9 +24,15 @@ import { XCircle, RefreshCw, Loader2, + MapPin, + Wallet, + CloudDownload, } from 'lucide-react'; +import { toast } from 'sonner'; import { cloudApi } from '@/api'; import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon'; +import { CloudAccountDetailSheet } from './CloudAccountDetailSheet'; +import { CloudServerImport } from '@/components/server/CloudServerImport'; import { PageShell } from '@/components/layout/PageShell'; import { PageLoading } from '@/components/layout/PageLoading'; import { PageEmpty } from '@/components/layout/PageEmpty'; @@ -63,9 +69,71 @@ export function CloudAccountManager() { monthly_cost: '', status: 'active', }); + const [detailAccount, setDetailAccount] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [importAccountId, setImportAccountId] = useState(); + const [actionLoading, setActionLoading] = useState>({}); const providerLabels = getProviderLabels(t); + const setCardAction = (accountId: string | number, action: string | null) => { + setActionLoading((prev) => { + const next = { ...prev }; + const key = String(accountId); + if (action) next[key] = action; + else delete next[key]; + return next; + }); + }; + + const openDetail = (account: JsonData) => { + setDetailAccount(account); + setDetailOpen(true); + }; + + const openImport = (account: JsonData) => { + setImportAccountId(account.id); + setImportOpen(true); + }; + + const handleQuickSyncRegions = async (account: JsonData) => { + setCardAction(account.id, 'regions'); + try { + await cloudApi.syncRegions(account.id); + toast.success(t('cloud.syncRegionsSuccess')); + loadData(); + } catch (e) { + toast.error(t('cloud.syncRegionsFailed'), { description: (e as JsonData).message }); + } finally { + setCardAction(account.id, null); + } + }; + + const handleQuickSyncBilling = async (account: JsonData) => { + setCardAction(account.id, 'billing'); + try { + const res = await cloudApi.syncBilling(account.id); + const errs = res?.errors || []; + if (errs.length > 0) { + toast.warning(t('cloud.syncBillingPartial'), { description: errs.join('\n') }); + } else { + toast.success(t('cloud.syncBillingSuccess'), { + description: t('cloud.syncBillingResult', { + date: res.billing_date, + created: res.created, + updated: res.updated, + }), + }); + } + loadData(); + } catch (e) { + toast.error(t('cloud.syncBillingFailed'), { description: (e as JsonData).message }); + } finally { + setCardAction(account.id, null); + } + }; + const resetForm = () => { setEditingAccount(null); setFormData({ @@ -178,7 +246,7 @@ export function CloudAccountManager() { onChange={(e) => setSelectedProvider(e.target.value)} className="px-3 py-2 h-9 rounded-md text-sm dg-input" > - + @@ -244,12 +312,18 @@ export function CloudAccountManager() {
-
-
+
+
-
-

{account.name}

+
+

{providerLabels[account.provider] || account.provider}

{accessKey && ( @@ -314,6 +388,40 @@ export function CloudAccountManager() {

+
+ + + +
); @@ -349,7 +457,7 @@ export function CloudAccountManager() {
setRdpUsername(e.target.value)} placeholder="Administrator" + className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /> +
+
+ + +
+
+ + setRdpDomain(e.target.value)} placeholder="可选" + className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /> +
+
+
+ )} + {secretType === 'docker' && (
@@ -394,4 +438,4 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia ); -} \ No newline at end of file +} diff --git a/frontend/src/components/deploy/credential-manager/constants.ts b/frontend/src/components/deploy/credential-manager/constants.ts index d3ac352..bddf65b 100644 --- a/frontend/src/components/deploy/credential-manager/constants.ts +++ b/frontend/src/components/deploy/credential-manager/constants.ts @@ -1,27 +1,28 @@ // 凭据类型配置常量 // Logan.Li import { - Key, Server, Container, Shield, Cloud, Database, FileText, Globe, + Key, Server, Container, Shield, Cloud, Database, FileText, Globe, Monitor, } from 'lucide-react'; import type { SecretType } from './types'; -import i18n from '@/i18n'; export const TYPE_CONFIG: Record = { - ssh: { label: i18n.t('system.sshKeyLabel'), icon: Server, color: 'dg-text-yellow', desc: i18n.t('pipeline.constantsts.sedc7f9e9') }, - docker: { label: i18n.t('system.dockerCredential'), icon: Container, color: 'dg-text-cyan', desc: i18n.t('pipeline.constantsts.sbd6cf171') }, - tls: { label: i18n.t('pipeline.constantsts.sfa5a64de'), icon: Shield, color: 'dg-text-green', desc: i18n.t('pipeline.constantsts.s43f00c61') }, - aksk: { label: i18n.t('pipeline.constantsts.sb82698a1'), icon: Cloud, color: 'dg-text-purple', desc: i18n.t('pipeline.constantsts.s564e98a9') }, - database: { label: i18n.t('system.dbCredential'), icon: Database, color: 'dg-text-blue', desc: i18n.t('pipeline.constantsts.se47498d3') }, - api: { label: i18n.t('system.apiKeys'), icon: Globe, color: 'dg-text-orange', desc: i18n.t('pipeline.constantsts.sb3edf0a4') }, - generic: { label: i18n.t('pipeline.constantsts.s1d319bb7'), icon: FileText, color: 'dg-text-secondary', desc: i18n.t('pipeline.constantsts.s51b5b280') }, + ssh: { label: 'SSH 密钥', icon: Server, color: 'dg-text-yellow', desc: 'SSH 私钥/公钥/密码' }, + rdp: { label: 'Windows RDP 密码', icon: Monitor, color: 'dg-text-blue', desc: 'Windows 远程桌面用户名/密码' }, + docker: { label: 'Docker 凭证', icon: Container, color: 'dg-text-cyan', desc: 'Docker Registry 登录凭据' }, + tls: { label: 'TLS 证书', icon: Shield, color: 'dg-text-green', desc: 'TLS/SSL 证书与私钥' }, + aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'dg-text-purple', desc: '云服务 AccessKey/SecretKey' }, + database: { label: '数据库凭证', icon: Database, color: 'dg-text-blue', desc: '数据库连接信息' }, + api: { label: 'API 密钥', icon: Globe, color: 'dg-text-orange', desc: 'API Token/Key + 端点' }, + generic: { label: '通用凭证', icon: FileText, color: 'dg-text-secondary', desc: '自定义 Key/Value 键值对' }, }; export const TYPE_BADGE_CLASS: Record = { ssh: 'bg-amber-500/20 dg-text-yellow border-amber-500/30', + rdp: 'bg-blue-500/20 dg-text-blue border-blue-500/30', docker: 'bg-cyan-500/20 dg-text-cyan border-cyan-500/30', tls: 'bg-green-500/20 dg-text-green border-green-500/30', aksk: 'bg-purple-500/20 dg-text-purple border-purple-500/30', database: 'bg-blue-500/20 dg-text-blue border-blue-500/30', api: 'bg-orange-500/20 dg-text-orange border-orange-500/30', generic: 'dg-badge-muted', -}; \ No newline at end of file +}; diff --git a/frontend/src/components/deploy/credential-manager/types.ts b/frontend/src/components/deploy/credential-manager/types.ts index ce14ab1..538e6e4 100644 --- a/frontend/src/components/deploy/credential-manager/types.ts +++ b/frontend/src/components/deploy/credential-manager/types.ts @@ -15,4 +15,4 @@ export interface Credential { updated_at: string; } -export type SecretType = 'ssh' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic'; \ No newline at end of file +export type SecretType = 'ssh' | 'rdp' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic'; diff --git a/frontend/src/components/deploy/deploy-rbac/JobRoleCreateDialog.tsx b/frontend/src/components/deploy/deploy-rbac/JobRoleCreateDialog.tsx index 3b03a90..13a6bbd 100644 --- a/frontend/src/components/deploy/deploy-rbac/JobRoleCreateDialog.tsx +++ b/frontend/src/components/deploy/deploy-rbac/JobRoleCreateDialog.tsx @@ -56,7 +56,7 @@ export function JobRoleCreateDialog({ const patternHint = useMemo(() => { if (!pattern.trim()) return t('rbac.jobRole.patternHint.empty'); try { - // eslint-disable-next-line no-new + new RegExp(pattern); return preview.length > 0 ? t('rbac.jobRole.patternHint.matched', { count: preview.length }) : t('rbac.jobRole.patternHint.noMatch'); } catch { diff --git a/frontend/src/components/deploy/releaseManagerHelpers.tsx b/frontend/src/components/deploy/releaseManagerHelpers.tsx index a522d0c..073a78f 100644 --- a/frontend/src/components/deploy/releaseManagerHelpers.tsx +++ b/frontend/src/components/deploy/releaseManagerHelpers.tsx @@ -3,6 +3,7 @@ import type { TFunction } from 'i18next'; import { Badge } from '@/components/ui/badge'; import { + type LucideIcon, Clock, Check, XCircle, @@ -28,7 +29,7 @@ const ENV_LABEL_KEY: Record = { // 发布状态徽章(审批流:待审批/已审批/已驳回/发布中/发布成功/发布失败/已回滚) // Logan.Li export function getStatusBadge(status: string, t: TFunction) { - const map: Record = { + const map: Record = { pending: { icon: Clock, labelKey: 'deploy.status.pendingApproval', cls: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' }, approved: { icon: Check, labelKey: 'deploy.status.approved', cls: 'border-purple-500/30 dg-text-purple bg-purple-500/10' }, rejected: { icon: XCircle, labelKey: 'deploy.status.rejected', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' }, diff --git a/frontend/src/components/kubernetes/DeploymentEditBasic.tsx b/frontend/src/components/kubernetes/DeploymentEditBasic.tsx index 900c3c5..ab54444 100644 --- a/frontend/src/components/kubernetes/DeploymentEditBasic.tsx +++ b/frontend/src/components/kubernetes/DeploymentEditBasic.tsx @@ -10,14 +10,14 @@ import { Badge } from '@/components/ui/badge'; import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { saveDeploymentManifest } from './deployment-manifest-save'; -import i18n from '@/i18n'; +import type { JsonData } from '@/types/common'; interface DeploymentEditBasicProps { clusterCode: string; namespace: string; deploymentName: string; - manifest: any | null; - onSaved: (updated?: any) => void; + manifest: JsonData | null; + onSaved: (updated?: JsonData) => void; onCancel: () => void; isCreate?: boolean; } @@ -42,7 +42,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma const { toast } = useToast(); const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(!manifest); - const [rawManifest, setRawManifest] = useState(manifest || {}); + const [rawManifest, setRawManifest] = useState(manifest || {}); const [resName, setResName] = useState(manifest?.metadata?.name || ''); const [replicas, setReplicas] = useState(1); const [labels, setLabels] = useState>([]); @@ -94,7 +94,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma const merged = { ...kvToObj(prev), ...sel }; return Object.entries(merged).map(([key, value]) => ({ key, value })); }); - toast({ title: i18n.t('kubernetes.deploymenteditb.s8bf97a5b'), description: i18n.t('kubernetes.deploymenteditb.s841d0da6') }); + toast({ title: '已同步', description: 'Selector 标签已合并到 Pod 模板标签' }); }; const save = async () => { @@ -107,7 +107,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma base.metadata = base.metadata || {}; if (isCreate) { const trimmed = resName.trim(); - if (!trimmed) throw new Error(i18n.t('k8s.hpa.nameRequired')); + if (!trimmed) throw new Error('名称不能为空'); base.metadata.name = trimmed; base.metadata.namespace = namespace; } @@ -128,10 +128,10 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma delete base.spec.template.metadata.labels; } const savedName = await saveDeploymentManifest(clusterCode, namespace, deploymentName, base); - toast({ title: isCreate ? i18n.t('monitor.dashboard.custom.toastCreateSuccess') : i18n.t('kubernetes.deploymenteditb.sa59c4d53'), description: isCreate ? `${savedName} 已创建` : `${savedName} 更新已提交` }); + toast({ title: isCreate ? '创建成功' : '基本信息已保存', description: isCreate ? `${savedName} 已创建` : `${savedName} 更新已提交` }); onSaved(base); } catch (e) { - toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: e?.message || String(e), variant: 'destructive' }); + toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' }); } finally { setSaving(false); } }; @@ -140,7 +140,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma -

{i18n.t('monitor.logPanel.logResults.loading')}

+

加载中...

); @@ -175,7 +175,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma onChange={e => setReplicas(Math.max(0, Number(e.target.value) || 0))} className="w-32 dg-input" /> - {i18n.t('kubernetes.deploymenteditb.s6e47fc82')} + 期望运行的 Pod 实例数量
@@ -185,12 +185,12 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma - {isCreate ? i18n.t('kubernetes.deploymenteditb.sc8de32ed') : i18n.t('kubernetes.deploymenteditb.sc4d9eb56')} + {isCreate ? '基本标识' : '基本标识(只读)'}
- +
- +
@@ -221,7 +221,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
- {labels.length === 0 &&

{i18n.t('monitor.logPanel.logResults.noLabels')}

} + {labels.length === 0 &&

无标签

} {labels.map((l, i) => (

Selector 决定哪些 Pod 归属此 Deployment。修改时必须确保与 Pod Template Labels 匹配,否则 Deployment 将无法管理 Pod。

- {selectorLabels.length === 0 &&

{i18n.t('kubernetes.service.noSelector')}

} + {selectorLabels.length === 0 &&

无选择器

} {selectorLabels.map((s, i) => (

Pod 模板标签必须包含 Selector 全部键值,否则 Deployment 无法管理 Pod

- {templateLabels.length === 0 &&

{i18n.t('kubernetes.deploymenteditb.se9c9f60d')}

} + {templateLabels.length === 0 &&

无模板标签

} {templateLabels.map((l, i) => (
- {annotations.length === 0 &&

{i18n.t('kubernetes.deploymenteditb.sbe1e7c07')}

} + {annotations.length === 0 &&

无注解

} {annotations.map((a, i) => (
PUT application/yaml 提交(自动剥离 status/managedFields 等只读字段)

- +
{/* 终端容器 */} -
+
+
+ +
); diff --git a/frontend/src/components/kubernetes/KubernetesClusterDetail.tsx b/frontend/src/components/kubernetes/KubernetesClusterDetail.tsx index e5050dd..0f43f9a 100644 --- a/frontend/src/components/kubernetes/KubernetesClusterDetail.tsx +++ b/frontend/src/components/kubernetes/KubernetesClusterDetail.tsx @@ -67,7 +67,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa { time: t('common.duration.min', { count: 2 }), cpu: Math.max(0, cpu * 0.96), memory: Math.max(0, mem * 0.97) }, { time: t('k8s.cluster.now'), cpu, memory: mem }, ]; - }, [clusterMetrics, t]); // eslint-disable-line react-hooks/exhaustive-deps + }, [clusterMetrics, t]); const loadClusterDetail = async (clusterCode: string) => { try { diff --git a/frontend/src/components/kubernetes/PodExecTerminal.tsx b/frontend/src/components/kubernetes/PodExecTerminal.tsx index cdcba65..af44e8a 100644 --- a/frontend/src/components/kubernetes/PodExecTerminal.tsx +++ b/frontend/src/components/kubernetes/PodExecTerminal.tsx @@ -20,9 +20,10 @@ import { Terminal, Maximize2, Minimize2, X, Loader2, RotateCw, Box, } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils'; +import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay'; import { kubernetesApi } from '@/api'; import { useToast } from '@/hooks/use-toast'; -import i18n from '@/i18n'; export interface PodExecTerminalProps { open: boolean; @@ -62,6 +63,7 @@ export function PodExecTerminal({ const [fullscreen, setFullscreen] = useState(false); const [container, setContainer] = useState(initialContainer || containers[0] || ''); const [shell, setShell] = useState('sh'); + const [hasOutput, setHasOutput] = useState(false); // ─── 重置 ─── useEffect(() => { @@ -92,6 +94,7 @@ export function PodExecTerminal({ } setConnState('connecting'); + setHasOutput(false); term.clear(); term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} shell=${shell} ...]\x1b[0m`); @@ -115,32 +118,39 @@ export function PodExecTerminal({ try { const msg = JSON.parse(event.data); if (msg.status === 0 && typeof msg.data === 'string') { + setHasOutput(true); term.write(msg.data); } else if (msg.status === 2 || msg.error) { - const errMsg = msg.data || msg.message || msg.error || i18n.t('kubernetes.podexecterminal.s05f6e923'); + setHasOutput(true); + const errMsg = msg.data || msg.message || msg.error || '执行错误'; if (errMsg.includes('not found') && shell === 'bash') { - term.writeln(i18n.t('kubernetes.podexecterminal.s14481be5')); + term.writeln('\r\n\x1b[33m⚠ bash 不可用,请切换 /bin/sh 后重连。\x1b[0m'); errorShownRef.current = true; return; } term.write(errMsg.includes('\x1b') ? errMsg : `\r\n\x1b[31m${errMsg}\x1b[0m\r\n`); } } catch { - if (typeof event.data === 'string') term.write(event.data); + if (typeof event.data === 'string') { + setHasOutput(true); + term.write(event.data); + } } }; ws.onclose = (_ev) => { setConnState((s) => (s === 'error' ? s : 'closed')); + setHasOutput(true); if (!errorShownRef.current) { - term.write(i18n.t('kubernetes.podexecterminal.s123d9435')); + term.write('\r\n\x1b[33m[连接已关闭]\x1b[0m\r\n'); } }; ws.onerror = (ev) => { console.error('[PodExec:connect] WS error event fired, readyState=', ws.readyState, 'type=', ev.type); setConnState('error'); - term.write(i18n.t('kubernetes.podexecterminal.sc8107726')); + setHasOutput(true); + term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n'); }; }, [clusterId, namespace, podName, container, shell]); @@ -200,8 +210,7 @@ export function PodExecTerminal({ } }); - term.open(el); - requestAnimationFrame(() => { try { fit.fit(); } catch { /* noop */ } }); + mountXterm(term, fit, () => el, wsRef.current); mountedRef.current = true; connect(); @@ -232,45 +241,37 @@ export function PodExecTerminal({ useEffect(() => { if (open && termRef.current && mountedRef.current) { connect(); - } else { } // eslint-disable-next-line react-hooks/exhaustive-deps }, [container, shell]); - // ─── ResizeObserver ─── + // ─── ResizeObserver:同步容器尺寸和远端 PTY 尺寸 // Logan.Li ─── useEffect(() => { if (!open) return; - const el = containerRef.current; - if (!el) return; - - let timer: ReturnType; - const ro = new ResizeObserver(() => { - clearTimeout(timer); - timer = setTimeout(() => { - if (fitRef.current && termRef.current) { - try { - fitRef.current.fit(); - termRef.current.focus(); - const { cols, rows } = termRef.current; - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ status: 1, cols, rows })); - } - } catch { /* noop */ } - } - }, 60); - }); - ro.observe(el); - return () => { clearTimeout(timer); ro.disconnect(); }; + let cleanup: (() => void) | undefined; + let retry = 0; + const attach = () => { + const container = containerRef.current; + const fit = fitRef.current; + const term = termRef.current; + if (!container || !fit || !term) { + if (retry++ < 30) requestAnimationFrame(attach); + return; + } + cleanup = attachTerminalResize(container, fit, term, wsRef.current); + }; + attach(); + return () => cleanup?.(); }, [open, fullscreen]); // ─── UI ─── const statusColor = connState === 'connected' ? 'bg-emerald-400' : connState === 'error' ? 'bg-red-400' : connState === 'closed' ? 'bg-[var(--dg-bg-active)]' : 'bg-amber-400'; - const statusText = connState === 'connected' ? i18n.t('agent.agentConnected') - : connState === 'connecting' ? i18n.t('common.connecting') - : connState === 'error' ? i18n.t('kubernetes.podexecterminal.sd5235918') - : connState === 'closed' ? i18n.t('agent.agentDisconnected') : i18n.t('common.idle'); + const statusText = connState === 'connected' ? '已连接' + : connState === 'connecting' ? '连接中' + : connState === 'error' ? '连接错误' + : connState === 'closed' ? '已断开' : '空闲'; const handleTerminalClick = useCallback(() => { try { termRef.current?.focus(); } catch { /* noop */ } @@ -290,7 +291,7 @@ export function PodExecTerminal({ style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '960px', width: '92vw' }} > exec — {podName} - {i18n.t('kubernetes.podexecterminal.s1a62d44d')} + 进入 Pod 容器执行命令 {/* 工具栏 */}
@@ -306,7 +307,7 @@ export function PodExecTerminal({ @@ -318,25 +319,27 @@ export function PodExecTerminal({ {SHELLS.map(s => {s.label})} - -
{/* 终端容器 */} -
+
+
+ +
); diff --git a/frontend/src/components/kubernetes/deployment-detail/CreateHPADialog.tsx b/frontend/src/components/kubernetes/deployment-detail/CreateHPADialog.tsx index 5d1bd7d..97b6981 100644 --- a/frontend/src/components/kubernetes/deployment-detail/CreateHPADialog.tsx +++ b/frontend/src/components/kubernetes/deployment-detail/CreateHPADialog.tsx @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { kubernetesApi } from '@/api'; import { useToast } from '@/hooks/use-toast'; -import { useTranslation } from 'react-i18next'; +import { getErrorMessage } from '@/lib/utils'; export interface CreateHPADialogProps { clusterCode: string; @@ -16,7 +16,6 @@ export interface CreateHPADialogProps { } export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCreated }: CreateHPADialogProps) { - const { t } = useTranslation(); const { toast } = useToast(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -30,7 +29,7 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea const handleCreate = async () => { if (!form.name.trim()) { - toast({ title: t('k8s.hpa.nameRequired'), variant: 'destructive' }); + toast({ title: '名称不能为空', variant: 'destructive' }); return; } setLoading(true); @@ -45,46 +44,46 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea cpuPercent: form.cpuPercent, memPercent: form.memPercent, }); - toast({ title: t('k8s.hpa.createSuccess') }); + toast({ title: 'HPA 创建成功' }); setOpen(false); onCreated(); setForm({ name: '', minReplicas: 1, maxReplicas: 10, cpuPercent: 80, memPercent: 0 }); - } catch (e: any) { - toast({ title: t('k8s.hpa.createFailed'), description: e?.message || String(e), variant: 'destructive' }); + } catch (e: unknown) { + toast({ title: '创建失败', description: getErrorMessage(e), variant: 'destructive' }); } finally { setLoading(false); } }; return ( <> {open && (
setOpen(false)}>
e.stopPropagation()}> -

{t('k8s.hpa.create')}

+

创建 HPA

- + setForm({ ...form, name: e.target.value })} placeholder={`hpa-${deploymentName}`} className="dg-input mt-1" />
- -

- {t('k8s.hpa.targetSummary', { deployment: deploymentName, namespace })} + +

+ Deployment: {deploymentName} · 命名空间: {namespace}

- + setForm({ ...form, minReplicas: Number(e.target.value) })} className="dg-input mt-1" />
- + setForm({ ...form, maxReplicas: Number(e.target.value) })} className="dg-input mt-1" /> @@ -92,13 +91,13 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
- + setForm({ ...form, cpuPercent: Number(e.target.value) })} className="dg-input mt-1" />
- + setForm({ ...form, memPercent: Number(e.target.value) })} className="dg-input mt-1" /> @@ -106,9 +105,9 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
- +
@@ -116,4 +115,4 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea )} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/kubernetes/deployment-manifest-save.ts b/frontend/src/components/kubernetes/deployment-manifest-save.ts index c43e6f6..f750535 100644 --- a/frontend/src/components/kubernetes/deployment-manifest-save.ts +++ b/frontend/src/components/kubernetes/deployment-manifest-save.ts @@ -2,16 +2,16 @@ import { kubernetesApi } from '@/api'; import { prepareManifestForSave } from './k8s-manifest-utils'; import { isCreateDeployment } from './deployment-constants'; -import i18n from '@/i18n'; +import type { JsonData } from '@/types/common'; export async function saveDeploymentManifest( clusterCode: string, namespace: string, deploymentName: string, - manifest: Record, + manifest: JsonData, ): Promise { const resName = String(manifest?.metadata?.name || '').trim(); - if (!resName) throw new Error(i18n.t('kubernetes.deploymentmanif.scb497faa')); + if (!resName) throw new Error('Deployment 名称不能为空'); const yaml = await prepareManifestForSave(manifest); if (isCreateDeployment(deploymentName)) { await kubernetesApi.applyYAML(clusterCode, yaml); @@ -19,4 +19,4 @@ export async function saveDeploymentManifest( await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', resName, yaml); } return resName; -} \ No newline at end of file +} diff --git a/frontend/src/components/kubernetes/deployment-workload/UpdateImageModal.tsx b/frontend/src/components/kubernetes/deployment-workload/UpdateImageModal.tsx index 31208d7..dd2fcf2 100644 --- a/frontend/src/components/kubernetes/deployment-workload/UpdateImageModal.tsx +++ b/frontend/src/components/kubernetes/deployment-workload/UpdateImageModal.tsx @@ -7,9 +7,10 @@ import { ArrowRight, Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { joinImageRef, parseImageRef } from '../image-ref-utils'; import { ModalShell } from './ModalShell'; +import type { JsonData } from '@/types/common'; export interface UpdateImageModalProps { - deployment: any; + deployment: JsonData; newImage: string; setNewImage: (value: string) => void; actionLoading: string; @@ -153,4 +154,4 @@ export function UpdateImageModal({
); -} \ No newline at end of file +} diff --git a/frontend/src/components/kubernetes/k8s-yaml-utils.ts b/frontend/src/components/kubernetes/k8s-yaml-utils.ts index 98a4865..54d3cc6 100644 --- a/frontend/src/components/kubernetes/k8s-yaml-utils.ts +++ b/frontend/src/components/kubernetes/k8s-yaml-utils.ts @@ -378,7 +378,7 @@ export function cleanYAML(yaml: string): string { // 系统 volumeMount 条目 if (inVolumeMounts && trimmed.startsWith('- name:')) { // 检查这个 volume mount 块内是否有系统路径 - let blockLines: string[] = [line]; + const blockLines: string[] = [line]; let j = i + 1; let isSystem = false; while (j < lines.length) { @@ -427,7 +427,7 @@ export function cleanYAML(yaml: string): string { } // 末尾清理 - let result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd(); + const result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd(); return result + '\n'; } diff --git a/frontend/src/components/layout/Breadcrumb.tsx b/frontend/src/components/layout/Breadcrumb.tsx index 8967e12..9f27178 100644 --- a/frontend/src/components/layout/Breadcrumb.tsx +++ b/frontend/src/components/layout/Breadcrumb.tsx @@ -1,73 +1,69 @@ import { Link, useLocation } from 'react-router-dom'; import { ChevronRight, Home } from 'lucide-react'; import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import i18n from '@/i18n'; /** * Route name mapping - breadcrumb display names */ const ROUTE_NAME_MAP: Record = { - '': i18n.t('menu.dashboard'), - 'servers': i18n.t('menu.servers'), - 'cloud-accounts': i18n.t('menu.cloudAccounts'), - 'domains': i18n.t('menu.domains'), - 'regions': i18n.t('menu.regions'), - 'platforms': i18n.t('menu.platforms'), - 'cloud-billing': i18n.t('menu.cloudBilling'), + '': '仪表盘', + 'servers': '服务器管理', + 'cloud-accounts': '多云账号', + 'domains': '域名管理', + 'cloud-billing': '云账单', 'kubernetes': 'Kubernetes', - 'clusters': i18n.t('menu.clusterList'), - 'nodes': i18n.t('menu.nodeManagement'), - 'workloads': i18n.t('menu.workloads'), + 'clusters': '集群列表', + 'nodes': '节点管理', + 'workloads': '工作负载', 'deployments': 'Deployments', 'statefulsets': 'StatefulSets', 'daemonsets': 'DaemonSets', - 'cronjobs': i18n.t('menu.cronJobs'), + 'cronjobs': '定时任务', 'services': 'Services', 'ingresses': 'Ingresses', 'configmaps': 'ConfigMaps', 'secrets': 'Secrets', 'hpas': 'HPA', - 'pvcs': i18n.t('menu.storage'), + 'pvcs': '存储', - 'events': i18n.t('menu.events'), - 'topology': i18n.t('menu.topology'), - 'namespace': i18n.t('k8s.column.namespace'), - 'workload': i18n.t('menu.workloads'), - 'view': i18n.t('common.view'), - 'edit': i18n.t('common.edit'), - 'pipelines': i18n.t('menu.pipelines'), - 'deploy': i18n.t('menu.deploy'), - 'configureTools': i18n.t('menu.deployConfig'), - 'groups': i18n.t('pipeline.userGroup'), - 'rbac': i18n.t('system.permissions'), - 'monitor-center': i18n.t('menu.resourceMonitor'), - 'log-center': i18n.t('menu.logCenter'), + 'events': '事件', + 'topology': '架构拓扑', + 'namespace': '命名空间', + 'workload': '工作负载', + 'view': '查看', + 'edit': '编辑', + 'pipelines': '流水线', + 'deploy': '应用发布', + 'configureTools': '发布配置', + 'groups': '用户组', + 'rbac': '权限管理', + 'monitor-center': '资源监控', + 'log-center': '日志中心', 'loki': 'Loki', 'es': 'ES', - 'logs': i18n.t('menu.logCenter'), - 'trace': i18n.t('menu.trace'), - 'alert-center': i18n.t('menu.alertCenter'), - 'datasources': i18n.t('menu.datasources'), - 'dashboards': i18n.t('menu.grafana'), - 'host-monitor': i18n.t('monitor.hostMonitor'), + 'logs': '日志中心', + 'trace': '链路追踪', + 'alert-center': '告警中心', + 'datasources': '数据源', + 'dashboards': '监控大屏', + 'host-monitor': '主机监控', 'alerts': '监控告警', - 'approval': i18n.t('menu.approval'), - 'approval-resource': i18n.t('menu.approval'), - 'approval-app': i18n.t('menu.approvalApp'), - 'approval-permission': i18n.t('menu.approvalPermission'), - 'approval-workflow': i18n.t('menu.approvalWorkflow'), - 'scripts': i18n.t('menu.scripts'), + 'approval': '资源申请', + 'approval-resource': '资源申请', + 'approval-app': '应用申请', + 'approval-permission': '权限审批', + 'approval-workflow': '审批流程', + 'scripts': '脚本库', 'ansible': 'Ansible', - 'ai-assistant': i18n.t('menu.aiAssistant'), - 'ai-alert-analyzer': i18n.t('aiops.alertAnalysis'), - 'ai-predictive': i18n.t('aiops.predictive'), - 'users': i18n.t('menu.users'), - 'roles': i18n.t('menu.roles'), - 'logs-audit': i18n.t('system.auditLog'), - 'terminal-audit': i18n.t('system.terminal'), - 'credentials': i18n.t('menu.credentials'), - 'settings': i18n.t('menu.settings'), + 'ai-assistant': '智能助手', + 'ai-alert-analyzer': '告警分析', + 'ai-predictive': '预测分析', + 'users': '用户管理', + 'roles': '角色权限', + 'logs-audit': '操作审计', + 'terminal-audit': '终端审计', + 'credentials': '凭证配置', + 'settings': '系统设置', }; interface BreadcrumbItem { @@ -83,13 +79,12 @@ interface BreadcrumbItem { * 支持动态参数(如 clusterCode, namespace, name)显示 */ export function Breadcrumb() { - const { t } = useTranslation(); const location = useLocation(); const breadcrumbs = useMemo(() => { const paths = location.pathname.split('/').filter(Boolean); if (paths.length === 0) { - return [{ name: t('menu.dashboard'), path: '/', isLast: true }]; + return [{ name: '仪表盘', path: '/', isLast: true }]; } const items: BreadcrumbItem[] = []; @@ -105,10 +100,10 @@ export function Breadcrumb() { // 日志中心子路由区分 // Logan.Li if (segment === 'alerts' && paths[index - 1] === 'log-center') { - displayName = t('menu.logAlerts'); + displayName = '日志告警'; } if (segment === 'datasources' && paths[index - 1] === 'log-center') { - displayName = t('menu.logLibrary'); + displayName = '日志库'; } if (!displayName) { @@ -138,13 +133,13 @@ export function Breadcrumb() { return (