Compare commits

...
Author SHA1 Message Date
guoyuqi f91db55daa refactor(frontend): unify dialogs and improve deletion flows 2026-10-27 14:52:05 +08:00
ssongliu 5346d5f58a fix(ai): reconcile Ollama models and improve engine management (#14211) 2026-10-27 11:06:32 +08:00
Raphael Fakhri ac09426f10 fix(app): keep signed numeric env values unchanged (#14180) 2026-10-27 09:58:53 +08:00
CityFun 19707a68dd fix(agent): support new Hermes Weixin module path (#14197) 2026-10-26 23:34:56 +08:00
ssongliu 7c7fd492c4 feat(ai): support benchmark report routes and parameter translations (#14210) 2026-10-26 23:33:17 +08:00
ssongliu fbf66766c2 fix: align AI menu order and labels (#14192) 2026-10-26 14:03:40 +08:00
蘭 d7355f3ccc feat(mcp): add 1Panel MCP and enhance MCP credential management (#14189)
* feat(mcp): add 1Panel MCP and enhance MCP credential management

* feat(i18n): implement MCP document localization and remove legacy code
2026-10-26 13:59:49 +08:00
CityFun 93ca724985 fix(appstore): refresh cached app resources and PHP runtime version options (#14185) 2026-10-26 11:29:29 +08:00
ssongliu 9481a7535d fix: align AI menu order and labels (#14183) 2026-10-26 11:29:20 +08:00
ssongliu 14be5b7870 feat(ai): translate benchmark comparison metrics (#14182) 2026-10-25 14:05:09 +08:00
ssongliu c4b76a8e89 feat(ai): unify model services and inference engine management (#14175) 2026-10-23 18:39:22 +08:00
Woolsenandssongliu 9295f4ef8a fix: correct 1pctl password complexity validation (#14169)
* fix: correct 1pctl password complexity validation

`isValidPassword("newPassword")` passed a literal string and the condition was inverted, so the complexity check never ran. Extract the validation into a testable helper and cover both the enabled and disabled paths.

Refs #12640

* Delete core/cmd/server/cmd/update_test.go

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-23 18:26:25 +08:00
Yuki Guo c925184bf6 fix: container list (#14156)
* fix: container list

* fix: container list
2026-10-23 18:19:52 +08:00
CityFun 87828b03ae fix(website): sync PHP runtime changes to subsites (#14166)
Update subsite upstreams and runtime references together with the parent site. Preserve stopped sites and custom PHP settings, and restore configurations and database records when applying the change fails.
2026-10-23 18:19:44 +08:00
Yuki Guo e7b4146e1b fix: operation log text (#14173) 2026-10-23 18:19:30 +08:00
Yuki Guo ec998552b9 feat: ca website upload (#14148) 2026-10-23 18:12:57 +08:00
Woolsen 1af237944d fix(file): reject rename when the target already exists (#13946) (#14168) 2026-10-23 14:05:48 +08:00
CityFun f9f9169a4d fix(build): upgrade Go to 1.27.3 (#14161) 2026-10-22 15:41:34 +08:00
zhengkunwang223 1e01761ba3 fix(appstore): refresh cached resources and PHP versions
Version official app package and compose downloads using the synchronized store revision. Refresh outdated local resource caches and use synchronized database parameters for official PHP version options.
2026-10-22 11:53:36 +08:00
CityFun 15283fde34 feat: update deepseek default model (#14146) 2026-10-21 18:37:22 +08:00
Octopusandocto-patch 49f8e7f040 feat(ai-agents): refresh MiniMax Flash models and defaults (#14136)
Co-authored-by: octo-patch <octo-patch@users.noreply.github.com>
2026-10-21 18:23:53 +08:00
Yuki Guo 98b27af6a0 fix: container list search (#14134) 2026-10-21 18:22:02 +08:00
ssongliu a393224729 feat(ai): support benchmark node options and report integration (#14145) 2026-10-21 18:07:39 +08:00
Woolsen b851c84290 fix(container): reset sort to default when cleared (#14140)
The container list opens with name ascending but shows no sort indicator. Clearing the name sort kept the previous descending order, so two no-indicator states represented different orders. Reset the request to the default name ascending when the sort is cleared.
2026-10-21 18:01:18 +08:00
Woolsen 372855157d feat(container): support sorting by creation time (#14141)
Add a creation time column to the container list. The column uses the backend createdAt order field and displays the existing createTime value. It follows the standard three-state sort cycle, starting with newest first.
2026-10-21 18:01:07 +08:00
CityFunandssongliu 577d771495 feat(agent): add DeepSeek Harness and OctoP support (#14138)
* feat(agent): add DeepSeek Harness and OctoP support

* fix: restore default core edition configuration

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-21 17:25:38 +08:00
Yuki Guo ca3e6511e5 feat: ssl push multiple directories (#14137) 2026-10-21 17:23:56 +08:00
Yuki Guo 8af14eae25 feat: add container catalog batch upload (#14133) 2026-10-21 17:09:01 +08:00
ssongliu 175fbaf3f2 fix: correct terminal node routing for containers (#14142) 2026-10-21 16:40:42 +08:00
Yuki Guo 13199efd62 feat: add overview more quick menu (#14132) 2026-10-21 16:21:07 +08:00
HynoRandCursor 04099ae2a2 feat(terminal): add session grace expiry tests and improve detach logic (#14127)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 17:54:05 +08:00
ssongliu c695a2a408 fix(firewall): prepare access whitelist before startup (#14114) 2026-10-16 17:27:48 +08:00
ssongliu 690cb48280 feat(vllm): refine monitor snapshots and historical request counts (#14109) 2026-10-16 13:36:29 +08:00
ssongliu f226fed8cd feat(firewall): support restoring rule backups on startup (#14108) 2026-10-16 10:50:07 +08:00
Yuki Guo 110c292856 chore: chore home default css (#14101) 2026-10-15 15:45:25 +08:00
Kadxyandssongliu 8e865c2c46 fix: stop translating the built-in Default group name (#14100)
* fix: stop translating the built-in Default group name

* fix: resolve default cronjob group by flag

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-15 15:42:18 +08:00
Kadxy 17b8f472e7 fix(cronjob): show Yes/No text instead of check icon in script library interactive column (#14099) 2026-10-15 15:25:03 +08:00
Yuki Guo d9dc5d7aa9 fix: alert config log text (#14086)
* fix: alert config log text

* fix: alert config log text
2026-10-15 09:33:23 +08:00
ssongliu 547f9775b0 fix(firewall): support legacy ip RA checks and link documentation (#14083) 2026-10-14 15:57:07 +08:00
ssongliu f37afb1a37 fix: sync core panel port whitelist on agent startup (#14080) 2026-10-14 11:28:46 +08:00
CityFun 3b89d83554 feat(hermes-agent): support version upgrades (#14079) 2026-10-14 11:28:32 +08:00
蘭 12bfe6a1bd fix: Fix Webhook Response Judgment and Optimize Configuration and Testing Experience (#14077) 2026-10-14 11:28:21 +08:00
ssongliu aaf70cfc41 fix(log): improve operation logs (#14076) 2026-10-13 18:28:34 +08:00
Yuki Guo 1ecd4f7f0c fix: container log text (#14066) 2026-10-13 17:56:52 +08:00
CityFun a8ccdcade7 fix(ai-gateway): support backup logic during app upgrades (#14072) 2026-10-13 17:28:42 +08:00
ssongliu 4a89c0c031 fix: prevent refresh loop after unbinding current node license (#14064) 2026-10-13 11:20:48 +08:00
ssongliu f21ebb1a36 chore(deps): consolidate dependency and security updates (#14063) 2026-10-13 11:20:43 +08:00
ssongliu 3011383b07 fix(compose): handle relative paths and preserve multi-file projects (#14062) 2026-10-13 11:03:17 +08:00
Yuki Guo fd12866897 fix: fix website files (#14042)
* refactor: refactor website files

* refactor: refactor website files
2026-10-13 10:56:30 +08:00
ssongliu bc6cf519ce fix: improve database file handling (#14043) 2026-10-13 09:39:27 +08:00
ssongliu f9c43989fe fix: improve firewall compatibility (#14046) 2026-10-13 09:39:18 +08:00
Yuki Guo e90b201001 refactor: remove user tooltip (#14041) 2026-10-12 18:18:44 +08:00
ssongliu f7394938f7 chore(deps): update remaining dependencies and VueUse 15 (#14040) 2026-10-12 16:05:35 +08:00
ssongliu dada7294dd fix(firewall): preserve fail2ban priority when binding iptables chains (#14039)
Refs #14019
2026-10-12 15:54:55 +08:00
ssongliu afcb94f6ef feat(vllm): add request metrics and optimize monitor history (#14032) 2026-10-12 15:54:42 +08:00
ssongliu 5a2e430dff fix(home): calculate overview rates from actual sample intervals (#14033) 2026-10-12 15:52:34 +08:00
Yuki Guo f2ca4f56e6 feat: add Netcup DNS (#14038) 2026-10-12 15:03:07 +08:00
ssongliu b6f609aaee chore(deps): update dependencies and apply security patches (#14037) 2026-10-12 14:49:01 +08:00
Yuki Guo c960c0137d refactor: refactor user info (#14036) 2026-10-12 14:17:13 +08:00
Yuki Guo 09a901294e fix: local image upload package (#14031) 2026-10-12 10:08:07 +08:00
Yuki Guo 4d481a8f31 refactor: refactor website ssl delete text (#14024) 2026-10-12 10:07:58 +08:00
Yuki Guo f1e0aa3391 feat(container): support local image uploads and improve import task … (#14025)
* feat(container): support local image uploads and improve import task logs

* feat(container): support local image uploads and improve import task logs

* fix(container): remove duplicate image load response helper
2026-10-12 09:41:36 +08:00
Yuki Guo f475500543 fix(frontend): reset search filters and pagination on page visits (#14016) 2026-10-10 17:56:38 +08:00
Yuki Guo 8415a1ce28 refactor: refactor user info (#14021) 2026-10-10 17:50:05 +08:00
Yuki Guo faf4933a02 refactor: refactor website ssl keyType default value (#14023) 2026-10-10 17:49:14 +08:00
Yuki Guo c692a9f5e3 refactor: refactor website upload css (#14022)
* refactor: refactor website upload css

* refactor: refactor website upload css
2026-10-10 17:48:57 +08:00
CityFun e7f0c21715 fix(vllm): resolve stuck startup state blocking operations (#14017) 2026-10-10 11:13:17 +08:00
ssongliu da7e8f1dbf fix: Remove duplicate TaskScopeVm (#14020) 2026-10-10 10:59:22 +08:00
Yuki Guoandssongliu 486cd5d0ad feat(redis): enable remote terminal through tracked background tasks (#14012)
* feat(redis): enable remote terminal through tracked background tasks

* feat(redis): enable remote terminal through tracked background tasks

* refactor(redis): remove redundant CLI status tracking

* refactor(redis): retain existing task execution flow

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-09 18:20:51 +08:00
ssongliu 00ce5a7b36 fix(firewall): preserve IPv6 RA protection and simplify error messages (#14015) 2026-10-09 17:45:57 +08:00
ssongliu c17f055e06 fix: preserve VM task timeout cleanup and terminate command groups (#14008) 2026-10-09 17:45:41 +08:00
王贺 f22df4b29c fix(file): stabilize closable tab width to avoid layout jitter (#14013)
The closable card tabs animated the close icon width between active and inactive tabs, so each tab switch reflowed the tab bar and caused the page to jitter. Reserve the close icon slot and toggle it with opacity instead, matching the terminal tab implementation.
2026-10-09 17:16:10 +08:00
Yuki Guoandssongliu b9bab90a14 fix: fix clean network (#14009)
* fix: fix clean network

* refactor(container): simplify network cleanup

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-09 17:14:50 +08:00
ssongliu ce14a104cc feat(vm): move shared VM support to agent (#14007) 2026-10-09 14:09:53 +08:00
CN_CoreStebandssongliu 2eaabccf94 fix(dashboard): prevent overlapping df collection and share disk queries (#13971)
* fix(dashboard): keep disk info working when a mount is stale or broken

The dashboard, the file manager mount list and the alert disk list
enumerated mounts with df. df calls statfs on every mount, so one bad
mount took the whole disk list down: the request hung until the
frontend timed out, or the list came back empty.

- enumerate mounts from /proc/self/mountinfo instead of running df
- read each mount through a guard with a 3s timeout that keeps at most
  one call per mount in flight
- fail closed: a mount that cannot be read stays in the list with
  errorType and error, and an unreadable mount table is an error
  rather than an empty list
- show failed mounts and their reasons on the dashboard and in the
  file manager
- cmd: after killing a timed-out command, wait at most 5s for it
  instead of forever

* fix(dashboard): distinguish remounted and hidden filesystems

* fix(disk): simplify disk queries and bound timeout handling

* fix(disk): prevent overlapping df collection tasks

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-09 10:14:16 +08:00
ssongliu e119239d3d refactor: manage firewall rules through runtime state and file backups (#13998)
Replace database rule synchronization with native rule operations and file-based backup, import, and recovery. Update firewall management views, descriptions, whitelist handling, and translations.

Include terminal reconnect fixes and xpack build tags for Darwin build targets. Remove temporary regression test files before committing.
2026-10-08 18:19:30 +08:00
ssongliu 14a57af4e9 feat(vm): clarify network modes and add macvtap translations (#13996) 2026-10-08 18:18:17 +08:00
Yuki Guoandssongliu 2701af9054 refactor: select bind (#13993)
* refactor: select bind

* refactor: simplify table selection state handling

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-08 17:11:28 +08:00
Yuki Guoandssongliu 4954067736 refactor: search bind (#13992)
* refactor: search bind

* refactor: simplify search persistence

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-08 16:19:29 +08:00
ssongliu b962144e7d fix(ssh): validate imported key pairs and cancel stale file reads (#13991) 2026-10-08 14:06:40 +08:00
Yuki Guo 8f1be42b6b fix: fix log text (#13990) 2026-10-08 11:32:09 +08:00
Yuki Guo c6969f231b fix: fix operation log (#13989) 2026-10-08 11:08:04 +08:00
4kerccand4kercc 3571fb8d86 fix: speed up SFTP backup upload with concurrent writes (#13976)
The SFTP uploader used io.Copy(dstFile, srcFile). Since the source is an
*os.File it implements io.WriterTo, so io.Copy took the os.File.WriteTo
branch and issued one synchronous SFTP WRITE per packet, waiting for the
server reply every time. Throughput was therefore bounded by packetSize/RTT
instead of the link capacity: on a ~68ms RTT link a 440MB backup reached
only ~0.25 MB/s, while rclone / OpenSSH sftp reach 16-19 MB/s on the very
same link and target.

pkg/sftp already provides a pipelined write path (File.ReadFrom, enabled by
UseConcurrentWrites and bounded by MaxConcurrentRequestsPerFile, default 64).
Use it for uploads, and truncate the remote file to the number of bytes
actually written when the concurrent write fails, to avoid leaving holes.

Measured against the same 68ms RTT target: a 96MiB upload went from 6m40s
(~0.25 MB/s) to ~6s (~16 MB/s); a 950MB three-site backup job from ~2h40m to
~2m04s.

Co-authored-by: 4kercc <13767585+4kercc@users.noreply.github.com>
2026-10-08 09:45:51 +08:00
ssongliu d21288788e fix: harden WebSocket origin validation (#13953) 2026-09-30 15:21:21 +08:00
ssongliu f65e4b06ab feat(vm): add VNC keyboard control translations (#13951)
* feat(vm): add VNC keyboard control translations

* feat(vm): add Windows VirtIO driver guidance translations

* feat(vm): add disk expansion translations and operation logs
2026-09-30 15:20:19 +08:00
ssongliu d26bc9a3a4 chore(deps): update dependencies and address security advisories (#13945)
Update pending Go and frontend dependencies, including gRPC, OpenTelemetry
and SVGO security fixes. Raise the minimum Go version to 1.26.6 and update
x/image and x/mod to patched versions.

Use moby/go-archive directly for image build contexts so its vulnerable
v0.1.0 replacement can be removed and v0.3.0 used. Regenerate Go module
checksums with go mod tidy and refresh the npm lockfile.

Validation: Linux amd64 builds, existing Go tests and go mod verify pass
for core and agent; frontend production build passes; npm audit reports
zero vulnerabilities. Frontend type-check reports the same nine errors
with the original and updated lockfiles.

Go scanning still flags two advisories in the legacy Docker SDK, which
has no patched release on its current module path. A separate SDK
migration and host Docker daemon updates remain necessary.
2026-09-29 21:36:19 +08:00
ssongliu 7088a903fe fix(vllm): sample realtime metrics independently of history (#13944) 2026-09-29 17:29:29 +08:00
ssongliu 864ebeed82 fix: load domestic release notes from new docs (#13943) 2026-09-29 17:29:16 +08:00
CN_CoreStebandssongliu c1b2b92708 feat(monitor): add monitor data export with csv, json and xlsx formats (#13875)
- new export tab between monitor and settings with time range, metric,
  device/interface/gpu selection and format choice
- files are generated in the browser from existing monitor apis; no new
  backend endpoints
- csv with utf-8 bom, escaping and formula sanitization; multiple metrics
  are zipped; xlsx uses one worksheet per metric with real date cells and
  row-limit warnings; json emits flat row objects
- share downloadBlob in utils/file with delayed url revocation
- use the 60s monitor timeout and node header for gpu queries

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-09-29 15:50:00 +08:00
CN_CoreStebandssongliu 2189d9229a perf(monitor): improve monitor query and chart performance for large ranges (#13874)
* perf(monitor): improve monitor query and chart performance for large ranges

- enable LTTB sampling in the shared line chart to bound rendered points
- empty io/network in monitor search now means no name filter, returning
  all device rows in one query instead of fanning out per device
- merge device names recorded in the monitor db into io/network/gpu
  options so removed devices stay selectable; move logic to service layer
- add composite indexes (name, created_at) on monitor_ios/monitor_networks
  and (product_name, created_at) on monitor_gpus
- stop migrating the unused MonitorGPU model into MonitorDB
- fix the memory param which was compared against "mem" and never matched

* chore(monitor): remove monitor test fixtures

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-09-29 15:34:16 +08:00
ssongliu d0183f460e feat: add vLLM monitoring (#13942) 2026-09-29 15:19:30 +08:00
ssongliu d27d6db1ea feat: enhance native GPU NPU and XPU monitoring (#13939) 2026-09-28 18:32:12 +08:00
蘭 d2bb3813d9 fix: enhance SSH login alert by adding local IP addresses to success login whitelist (#13920) 2026-09-24 18:23:59 +08:00
蘭 cac73e6d45 fix: add success alert message for cron jobs and enhance alert handling (#13919) 2026-09-24 18:23:39 +08:00
ssongliu 4712ceaf2c fix: allow read-only firewall APIs in demo mode (#13914) 2026-09-24 14:01:05 +08:00
ssongliu 65243c68c4 fix: allow licensed community restore progress route (#13913) 2026-09-24 10:18:01 +08:00
ssongliu 256e79ca81 fix: remove minimum upgrade backup copies limit (#13907) 2026-09-23 18:17:32 +08:00
ssongliu 4861eb69cb fix: separate firewalld batches by native option (#13896) 2026-09-22 18:20:18 +08:00
蘭 fb8cf15537 fix: Fix the automatic renewal certificate alert issue (#13895) 2026-09-22 15:06:48 +08:00
ssongliu c4a6791271 fix: allow updated SSH ports through firewall before restart (#13894) 2026-09-22 14:51:54 +08:00
蘭 f58e147636 fix: enhance file rename functionality (#13891) 2026-09-22 12:49:05 +08:00
ssongliu 387e9fbeed fix: correct firewall port allowances and whitelist sync reporting (#13892) 2026-09-22 12:48:40 +08:00
ssongliu 4eb627bc79 fix(vm): display additional virtual machine states (#13890) 2026-09-22 10:27:49 +08:00
ssongliu 850c86229c fix: log dashboard disk command failures (#13889) 2026-09-22 10:16:54 +08:00
CityFun f984917a66 fix(upgrade): resolve application upgrade failures (#13888) 2026-09-22 10:11:13 +08:00
A_Words 8588217fbf fix: prioritize HTTP-to-HTTPS redirects over website redirects (#13847) 2026-09-22 10:05:53 +08:00
ssongliu a2307c5f64 fix(firewall): improve batch operations and task log completion (#13886) 2026-09-22 09:46:46 +08:00
蘭 5923290de8 ref: add file task messages for copy, move, compress and decompress (#13885) 2026-09-21 17:32:14 +08:00
蘭 1c994fba4a ref: update documentation URL for the panel (#13884) 2026-09-21 17:28:23 +08:00
蘭 415ab96aab ref:improve the panel API key (#13883) 2026-09-21 17:18:15 +08:00
ssongliu 19bb823b05 fix(firewall): guard IPv6 forwarding against RA disruption (#13882) 2026-09-21 17:12:17 +08:00
ssongliu 3a5371652e refactor(firewall): simplify state reads and batch verification (#13881) 2026-09-21 15:09:13 +08:00
蘭 a267b4148a ref:Optimize and improve the panel API key (#13880) 2026-09-21 12:49:45 +08:00
蘭 36a01eb60d feat: The panel API key supports creating multiple tokens and enhance terminal capabilities (#13872) 2026-09-20 18:32:54 +08:00
王贺 65f6fdd045 fix(ssh): display SSH login logs in server timezone (#13869)
The SSH log page formatted dates with the browser timezone, so logs looked shifted when the client timezone differed from the server. Format the timestamp with the offset returned by the API so the page matches auth.log, the terminal and the CSV export. Refs #13860.
2026-09-20 18:02:46 +08:00
ssongliu 75b60b32e4 feat(vm): update multi-CD-ROM guidance translations (#13857) 2026-09-18 16:38:31 +08:00
CityFun 6cb65e2290 fix: Fixed with ollama page not work (#13855) 2026-09-18 11:35:46 +08:00
646 changed files with 52003 additions and 24252 deletions
+12 -16
View File
@@ -72,22 +72,6 @@ func (b *BaseApi) CloseOllamaModel(c *gin.Context) {
helper.SuccessWithData(c, nil) helper.SuccessWithData(c, nil)
} }
// @Tags AI
// @Summary Sync Ollama model list
// @Success 200 {array} dto.OllamaModelDropList
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/ollama/model/sync [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 Ollama 模型列表","formatEN":"sync Ollama model list"}
func (b *BaseApi) SyncOllamaModel(c *gin.Context) {
list, err := aiToolService.Sync()
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags AI // @Tags AI
// @Summary Page Ollama models // @Summary Page Ollama models
// @Accept json // @Accept json
@@ -114,6 +98,18 @@ func (b *BaseApi) SearchOllamaModel(c *gin.Context) {
}) })
} }
func (b *BaseApi) SyncOllamaModelAccount(c *gin.Context) {
var req dto.OllamaModelAccountSync
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := aiToolService.SyncAccount(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI // @Tags AI
// @Summary Page Ollama models // @Summary Page Ollama models
// @Accept json // @Accept json
+16
View File
@@ -0,0 +1,16 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) AIBenchmarkSources(c *gin.Context) {
sources, err := service.AIBenchmarkSources(c.Query("kind"), c.Query("selected"))
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, sources)
}
+28 -8
View File
@@ -10,6 +10,9 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/webhook_sender"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -290,7 +293,7 @@ func (b *BaseApi) PageAlertConfig(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /alert/config/update [post] // @Router /alert/config/update [post]
// @x-panel-log {"bodyKeys":["id","displayName"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置 [id][displayName]","formatEN":"update alert config [id][displayName]"} // @x-panel-log {"bodyKeys":["displayName"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置 [displayName]","formatEN":"update alert config [displayName]"}
func (b *BaseApi) UpdateAlertConfig(c *gin.Context) { func (b *BaseApi) UpdateAlertConfig(c *gin.Context) {
var req dto.AlertConfigUpdate var req dto.AlertConfigUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
@@ -318,13 +321,15 @@ func (b *BaseApi) UpdateAlertConfig(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /alert/config/status [post] // @Router /alert/config/status [post]
// @x-panel-log {"bodyKeys":["id","status"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置状态 [id][status]","formatEN":"update alert config status [id][status]"} // @x-panel-log {"bodyKeys":["status"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置状态 [name][status]","formatEN":"update alert config status [name][status]"}
func (b *BaseApi) UpdateAlertConfigStatus(c *gin.Context) { func (b *BaseApi) UpdateAlertConfigStatus(c *gin.Context) {
var req dto.AlertConfigStatusUpdate var req dto.AlertConfigStatusUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if err := alertService.UpdateAlertConfigStatus(req, loadAuditUser(c)); err != nil { name, err := alertService.UpdateAlertConfigStatus(req, loadAuditUser(c))
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
@@ -350,13 +355,14 @@ func loadAuditUser(c *gin.Context) string {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /alert/config/del [post] // @Router /alert/config/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除告警配置 [id]","formatEN":"delete alert config [id]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除告警配置 [name]","formatEN":"delete alert config [name]"}
func (b *BaseApi) DeleteAlertConfig(c *gin.Context) { func (b *BaseApi) DeleteAlertConfig(c *gin.Context) {
var req dto.DeleteRequest var req dto.DeleteRequest
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
err := alertService.DeleteAlertConfig(req.ID) name, err := alertService.DeleteAlertConfig(req.ID)
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -378,10 +384,24 @@ func (b *BaseApi) TestAlertConfig(c *gin.Context) {
return return
} }
if req.Type == constant.Custom { if req.Type == constant.Custom {
result, err := alertService.TestCustomAlertConfig(req) lang := strings.TrimSpace(c.GetHeader("Accept-Language"))
if lang == "" {
lang = i18n.GetLanguageFromDB()
}
result, err := alertService.TestCustomAlertConfig(req, lang)
if err != nil { if err != nil {
helper.InternalServer(c, err) if global.LOG != nil {
return global.LOG.Warnf("webhook test preparation failed: configID=%d error=%q", req.ID,
webhook_sender.DiagnosticText(err.Error(), webhook_sender.Request{
Format: webhook_sender.BodyJSON, Body: []byte(req.Config),
}))
}
result = dto.AlertConfigTestResult{Success: false}
}
result.Response = ""
result.Message = ""
if !result.Success {
result.Message = i18n.GetMsgByKeyAndLang(lang, "ErrWebhookTestFailed")
} }
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
return return
+16
View File
@@ -8,6 +8,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon" "github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -160,10 +161,25 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
// @Router /apps/install [post] // @Router /apps/install [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
func (b *BaseApi) InstallApp(c *gin.Context) { func (b *BaseApi) InstallApp(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req request.AppInstallCreate var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
req.MCPPreserveWebsiteDir = true
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
}
install, err := appService.Install(req, true) install, err := appService.Install(req, true)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+18
View File
@@ -1,7 +1,11 @@
package v2 package v2
import ( import (
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -336,11 +340,25 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get] // @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) { func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId") appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId) info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+9 -3
View File
@@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) {
return return
} }
total, list, err := containerService.Page(req) total, list, err := containerService.Page(c.Request.Context(), req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) {
// @Success 200 {object} dto.ContainerStatus // @Success 200 {object} dto.ContainerStatus
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Param containersOnly query boolean false "Only count containers"
// @Router /containers/status [get] // @Router /containers/status [get]
func (b *BaseApi) LoadContainerStatus(c *gin.Context) { func (b *BaseApi) LoadContainerStatus(c *gin.Context) {
data, err := containerService.LoadStatus() data, err := containerService.LoadStatus(c.Request.Context(), c.Query("containersOnly") == "true")
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) {
// @Success 200 {array} dto.ContainerListStats // @Success 200 {array} dto.ContainerListStats
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Param ids query string false "Comma-separated container IDs; omitted selects all containers"
// @Router /containers/list/stats [get] // @Router /containers/list/stats [get]
func (b *BaseApi) ContainerListStats(c *gin.Context) { func (b *BaseApi) ContainerListStats(c *gin.Context) {
data, err := containerService.ContainerListStats() var ids []string
if _, supplied := c.Request.URL.Query()["ids"]; supplied {
ids = strings.FieldsFunc(c.Query("ids"), func(r rune) bool { return r == ',' })
}
data, err := containerService.ContainerListStats(c.Request.Context(), ids)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
+17
View File
@@ -2,9 +2,12 @@ package v2
import ( import (
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -98,11 +101,25 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
// @Param type path string true "type" // @Param type path string true "type"
// @Router /databases/db/list/:type [get] // @Router /databases/db/list/:type [get]
func (b *BaseApi) ListDatabase(c *gin.Context) { func (b *BaseApi) ListDatabase(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
dbType, err := helper.GetStrParamByKey(c, "type") dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
return
}
list, err := databaseService.List(dbType) list, err := databaseService.List(dbType)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+32
View File
@@ -3,9 +3,12 @@ package v2
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -19,6 +22,11 @@ import (
// @Router /databases [post] // @Router /databases [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
func (b *BaseApi) CreateMysql(c *gin.Context) { func (b *BaseApi) CreateMysql(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.MysqlDBCreate var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
@@ -32,6 +40,16 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
} }
req.Password = string(password) req.Password = string(password)
} }
if mcpRequest {
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
return
}
if _, err := mysqlService.Create(context.Background(), req); err != nil { if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
@@ -513,11 +531,25 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /databases/status [post] // @Router /databases/status [post]
func (b *BaseApi) LoadStatus(c *gin.Context) { func (b *BaseApi) LoadStatus(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.OperationWithNameAndType var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
return
}
data, err := mysqlService.LoadStatus(req) data, err := mysqlService.LoadStatus(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+10 -4
View File
@@ -86,17 +86,23 @@ func (b *BaseApi) CheckHasCli(c *gin.Context) {
// @Tags Database Redis // @Tags Database Redis
// @Summary Install redis-cli // @Summary Install redis-cli
// @Success 200 // @Accept json
// @Param request body dto.RedisCliInstall true "request"
// @Success 200 {string} string
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /databases/redis/install/cli [post] // @Router /databases/redis/install/cli [post]
func (b *BaseApi) InstallCli(c *gin.Context) { func (b *BaseApi) InstallCli(c *gin.Context) {
if err := redisService.InstallCli(); err != nil { var req dto.RedisCliInstall
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := redisService.InstallCli(req)
if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, data)
helper.Success(c)
} }
// @Tags Database Redis // @Tags Database Redis
+109 -158
View File
@@ -2,14 +2,14 @@ package v2
import ( import (
"errors" "errors"
"github.com/1Panel-dev/1Panel/agent/buserr"
"net/http" "net/http"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter" "github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -88,7 +88,7 @@ func (b *BaseApi) OperateFirewall(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/forward/base [post] // @Router /hosts/firewall/forward/base [post]
func (b *BaseApi) LoadForwardingBaseInfo(c *gin.Context) { func (b *BaseApi) LoadForwardingBaseInfo(c *gin.Context) {
data, err := forwardingService.LoadBaseInfo() data, err := forwardingService.LoadBaseInfo(c.Request.Context())
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -109,7 +109,7 @@ func (b *BaseApi) SearchForwardingRules(c *gin.Context) {
if err := helper.CheckBindAndValidate(&request, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
total, items, err := forwardingService.SearchRules(request) total, items, err := forwardingService.SearchRules(c.Request.Context(), request)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -260,28 +260,6 @@ func (b *BaseApi) LoadFirewallNativeDetail(c *gin.Context) {
helper.SuccessWithData(c, info) helper.SuccessWithData(c, info)
} }
// @Tags Firewall
// @Summary Adopt an external firewall rule
// @Accept json
// @Param request body dto.FirewallRuleAdopt true "request"
// @Success 200
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/adopt [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"纳管防火墙规则","formatEN":"adopt firewall rule"}
func (b *BaseApi) AdoptFirewallRule(c *gin.Context) {
var request dto.FirewallRuleAdopt
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if err := firewallService.Adopt(c.Request.Context(), request); err != nil {
handleFirewallRuleError(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall // @Tags Firewall
// @Summary Queue firewall rule creation // @Summary Queue firewall rule creation
// @Description Creation and import return a taskID immediately; validation and execution results are written to the task log. // @Description Creation and import return a taskID immediately; validation and execution results are written to the task log.
@@ -307,69 +285,9 @@ func (b *BaseApi) CreateFirewallRules(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags Firewall
// @Summary Preview firewall rule synchronization
// @Accept json
// @Param request body dto.FirewallRuleSyncRequest true "request"
// @Success 200 {object} dto.FirewallRuleSyncPreview
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/sync/preview [post]
func (b *BaseApi) PreviewFirewallRuleSync(c *gin.Context) {
var request dto.FirewallRuleSyncRequest
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallService.PreviewRuleSync(c.Request.Context(), c.ClientIP(), request)
if err != nil {
handleFirewallRuleError(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Load the currently executing firewall rule synchronization task
// @Success 200 {object} dto.FirewallRuleSyncTask
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/sync/task [get]
func (b *BaseApi) LoadFirewallRuleSyncTask(c *gin.Context) {
result, err := firewallService.CurrentRuleSyncTask()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Synchronize firewall rules to a target backend
// @Accept json
// @Param request body dto.FirewallRuleSyncRequest true "request"
// @Success 200 {object} dto.FirewallRuleSyncResult
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/sync [post]
// @x-panel-log {"bodyKeys":["subsystem","sourceProvider","targetProvider"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 [subsystem] 防火墙规则到 [targetProvider]","formatEN":"sync [subsystem] firewall rules to [targetProvider]"}
func (b *BaseApi) SyncFirewallRules(c *gin.Context) {
var request dto.FirewallRuleSyncRequest
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallService.SyncRules(c.Request.Context(), c.ClientIP(), request)
if err != nil {
handleFirewallRuleError(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall // @Tags Firewall
// @Summary Queue firewall rule deletion // @Summary Queue firewall rule deletion
// @Description Deletes managed rules by UUID or unprotected before-chain rules by instance key. Returns a taskID immediately; results are written to the task log. // @Description Deletes non-whitelist rules by scope and instance key. Returns a taskID immediately; results are written to the task log.
// @Accept json // @Accept json
// @Param request body dto.FirewallRuleDelete true "request" // @Param request body dto.FirewallRuleDelete true "request"
// @Success 200 {object} dto.FirewallRuleDeleteResponse // @Success 200 {object} dto.FirewallRuleDeleteResponse
@@ -392,7 +310,7 @@ func (b *BaseApi) DeleteFirewallRules(c *gin.Context) {
} }
// @Tags Firewall // @Tags Firewall
// @Summary Update a managed unified firewall v2 rule // @Summary Update a firewall rule
// @Accept json // @Accept json
// @Param request body dto.FirewallRuleUpdate true "request" // @Param request body dto.FirewallRuleUpdate true "request"
// @Success 200 // @Success 200
@@ -400,16 +318,13 @@ func (b *BaseApi) DeleteFirewallRules(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/rules/update [post] // @Router /hosts/firewall/rules/update [post]
// @x-panel-log {"bodyKeys":["uuid"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新防火墙规则 [uuid]","formatEN":"update firewall rule [uuid]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新防火墙规则","formatEN":"update firewall rule"}
func (b *BaseApi) UpdateFirewallRule(c *gin.Context) { func (b *BaseApi) UpdateFirewallRule(c *gin.Context) {
var request dto.FirewallRuleUpdate var request dto.FirewallRuleUpdate
if err := helper.CheckBindAndValidate(&request, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
if !normalizeFirewallRuleUUID(c, &request.UUID) { if err := firewallService.Update(c.Request.Context(), request); err != nil {
return
}
if err := firewallService.Update(c.Request.Context(), c.ClientIP(), request); err != nil {
handleFirewallRuleError(c, err) handleFirewallRuleError(c, err)
return return
} }
@@ -417,7 +332,7 @@ func (b *BaseApi) UpdateFirewallRule(c *gin.Context) {
} }
// @Tags Firewall // @Tags Firewall
// @Summary Reorder a managed unified firewall v2 rule // @Summary Reorder a firewall rule
// @Accept json // @Accept json
// @Param request body dto.FirewallRuleReorder true "request" // @Param request body dto.FirewallRuleReorder true "request"
// @Success 200 // @Success 200
@@ -425,53 +340,41 @@ func (b *BaseApi) UpdateFirewallRule(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/rules/reorder [post] // @Router /hosts/firewall/rules/reorder [post]
// @x-panel-log {"bodyKeys":["uuid"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"调整防火墙规则顺序 [uuid]","formatEN":"reorder firewall rule [uuid]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"调整防火墙规则顺序","formatEN":"reorder firewall rule"}
func (b *BaseApi) ReorderFirewallRule(c *gin.Context) { func (b *BaseApi) ReorderFirewallRule(c *gin.Context) {
var request dto.FirewallRuleReorder var request dto.FirewallRuleReorder
if err := helper.CheckBindAndValidate(&request, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
if !normalizeFirewallRuleUUID(c, &request.UUID) { if err := firewallService.Reorder(c.Request.Context(), request); err != nil {
return
}
if err := firewallService.Reorder(c.Request.Context(), c.ClientIP(), request); err != nil {
handleFirewallRuleError(c, err) handleFirewallRuleError(c, err)
return return
} }
helper.Success(c) helper.Success(c)
} }
func normalizeFirewallRuleUUID(c *gin.Context, value *string) bool {
if value == nil {
helper.BadRequest(c, repo.ErrFirewallPersistenceInvalid)
return false
}
*value = strings.TrimSpace(*value)
if *value == "" {
helper.BadRequest(c, repo.ErrFirewallPersistenceInvalid)
return false
}
return true
}
func handleFirewallRuleError(c *gin.Context, err error) { func handleFirewallRuleError(c *gin.Context, err error) {
var businessErr buserr.BusinessError
isBusinessError := errors.As(err, &businessErr)
switch { switch {
case errors.Is(err, filter.ErrProtectedRule): case errors.Is(err, filter.ErrProtectedRule):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_LOCKOUT_RISK", "ErrInvalidParams", err) helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_LOCKOUT_RISK", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrRuleStale): case errors.Is(err, filter.ErrRuleStale):
helper.ErrorWithBusinessCode(c, http.StatusConflict, "FW_RULE_STALE", "ErrInvalidParams", err) helper.ErrorWithBusinessCode(c, http.StatusConflict, "FW_RULE_STALE", "ErrInvalidParams", err)
case errors.Is(err, repo.ErrFirewallRuleRevisionConflict):
helper.ErrorWithBusinessCode(c, http.StatusConflict, "FW_RULE_REVISION_CONFLICT", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrManagedScopeChange):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_SCOPE_UNSUPPORTED", "ErrFirewallRuleScopeChange", err)
case errors.Is(err, filter.ErrUnsupportedScope), errors.Is(err, filter.ErrInvalidScope), case errors.Is(err, filter.ErrUnsupportedScope), errors.Is(err, filter.ErrInvalidScope),
errors.Is(err, filter.ErrProviderUnavailable), errors.Is(err, filter.ErrAdapterUnavailable): errors.Is(err, filter.ErrProviderUnavailable), errors.Is(err, filter.ErrAdapterUnavailable):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_SCOPE_UNSUPPORTED", "ErrInvalidParams", err) helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_SCOPE_UNSUPPORTED", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrInvalidRule), errors.Is(err, filter.ErrRuleOperation), errors.Is(err, filter.ErrRuleConflict), case errors.Is(err, filter.ErrInvalidRule), errors.Is(err, filter.ErrRuleOperation):
errors.Is(err, repo.ErrFirewallPersistenceInvalid):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_RULE_UNSUPPORTED", "ErrInvalidParams", err) helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_RULE_UNSUPPORTED", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrVerificationFailed): case isBusinessError && businessErr.Msg == "ErrRecordExist":
helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_VERIFY_FAILED", "ErrInternalServer", err) c.JSON(http.StatusOK, dto.Response{Code: http.StatusConflict, ErrorCode: "FW_RULE_DUPLICATE", Message: err.Error()})
c.Abort()
case isBusinessError && businessErr.Msg == "ErrFirewallRuleConflict":
c.JSON(http.StatusOK, dto.Response{Code: http.StatusConflict, ErrorCode: "FW_RULE_CONFLICT", Message: err.Error()})
c.Abort()
case isBusinessError && businessErr.Msg == "ErrInvalidParams":
c.JSON(http.StatusOK, dto.Response{Code: http.StatusBadRequest, ErrorCode: "FW_RULE_UNSUPPORTED", Message: err.Error()})
c.Abort()
default: default:
helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_APPLY_FAILED", "ErrInternalServer", err) helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_APPLY_FAILED", "ErrInternalServer", err)
} }
@@ -494,7 +397,7 @@ func (b *BaseApi) LoadFirewallSettings(c *gin.Context) {
// @Tags Firewall // @Tags Firewall
// @Summary Create firewall port whitelist rules // @Summary Create firewall port whitelist rules
// @Description Saves whitelist configuration only. Missing rules are added on startup, restart, initialization, or synchronization; existing rules are not removed. // @Description Saves whitelist configuration and applies missing allowances; existing rules are not removed.
// @Accept json // @Accept json
// @Param request body dto.FirewallPortWhitelistCreate true "request" // @Param request body dto.FirewallPortWhitelistCreate true "request"
// @Success 200 // @Success 200
@@ -516,7 +419,7 @@ func (b *BaseApi) CreateFirewallPortWhitelist(c *gin.Context) {
// @Tags Firewall // @Tags Firewall
// @Summary Update firewall port whitelist rules // @Summary Update firewall port whitelist rules
// @Description Saves whitelist configuration only. Missing rules are added on startup, restart, initialization, or synchronization; existing rules are not removed. // @Description Saves whitelist configuration and applies missing allowances; existing rules are not removed.
// @Accept json // @Accept json
// @Param request body dto.FirewallPortWhitelistUpdate true "request" // @Param request body dto.FirewallPortWhitelistUpdate true "request"
// @Success 200 // @Success 200
@@ -538,7 +441,7 @@ func (b *BaseApi) UpdateFirewallPortWhitelist(c *gin.Context) {
// @Tags Firewall // @Tags Firewall
// @Summary Delete firewall port whitelist rules // @Summary Delete firewall port whitelist rules
// @Description Saves whitelist configuration only. Missing rules are added on startup, restart, initialization, or synchronization; existing rules are not removed. // @Description Removes whitelist configuration; existing firewall rules are not removed.
// @Accept json // @Accept json
// @Param request body dto.FirewallPortWhitelistDelete true "request" // @Param request body dto.FirewallPortWhitelistDelete true "request"
// @Success 200 // @Success 200
@@ -573,14 +476,10 @@ func (b *BaseApi) OperateFirewallBackend(c *gin.Context) {
return return
} }
if err := firewallSettingService.Operate(c.Request.Context(), request); err != nil { if err := firewallSettingService.Operate(c.Request.Context(), request); err != nil {
if errors.Is(err, service.ErrFirewallBackendCleanupRequired) { var businessErr buserr.BusinessError
helper.ErrorWithBusinessCode( if errors.As(err, &businessErr) && businessErr.Msg == "ErrFirewallBackendCleanupRequired" {
c, c.JSON(http.StatusOK, dto.Response{Code: http.StatusConflict, ErrorCode: "FW_BACKEND_CLEANUP_REQUIRED", Message: err.Error()})
http.StatusConflict, c.Abort()
"FW_BACKEND_CLEANUP_REQUIRED",
"ErrInvalidParams",
err,
)
return return
} }
helper.InternalServer(c, err) helper.InternalServer(c, err)
@@ -619,21 +518,6 @@ func (b *BaseApi) ListDockerPublishedPorts(c *gin.Context) {
helper.SuccessWithData(c, data) helper.SuccessWithData(c, data)
} }
// @Tags Firewall
// @Summary Sync Docker port guard rules
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/docker/sync [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 Docker 端口防护规则","formatEN":"sync Docker port guard rules"}
func (b *BaseApi) SyncDockerPortGuard(c *gin.Context) {
if err := dockerPortGuardService.Reconcile(c.Request.Context()); err != nil {
handleDockerPortGuardError(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall // @Tags Firewall
// @Summary Operate Docker port guard // @Summary Operate Docker port guard
// @Accept json // @Accept json
@@ -672,7 +556,7 @@ func (b *BaseApi) OperateDockerPortGuard(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/docker/policies/delete/batch [post] // @Router /hosts/firewall/docker/policies/delete/batch [post]
// @x-panel-log {"bodyKeys":["uuids"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除 Docker 端口防护策略 [uuids]","formatEN":"delete Docker port guard policies [uuids]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除 Docker 端口防护策略","formatEN":"delete Docker port guard policies"}
func (b *BaseApi) DeleteDockerPortGuardPolicies(c *gin.Context) { func (b *BaseApi) DeleteDockerPortGuardPolicies(c *gin.Context) {
var request dto.DockerPortGuardPolicyBatchDelete var request dto.DockerPortGuardPolicyBatchDelete
if err := helper.CheckBindAndValidate(&request, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
@@ -709,21 +593,88 @@ func (b *BaseApi) UpsertDockerPortGuardPolicies(c *gin.Context) {
} }
func handleDockerPortGuardError(c *gin.Context, err error) { func handleDockerPortGuardError(c *gin.Context, err error) {
if errors.Is(err, service.ErrDockerIptablesChainUnavailable) { var businessErr buserr.BusinessError
helper.ErrorWithBusinessCode(c, http.StatusServiceUnavailable, "FW_DOCKER_IPTABLES_CHAIN_UNAVAILABLE", "ErrDockerIptablesChainUnavailable", err) if errors.As(err, &businessErr) {
code, errorCode := http.StatusInternalServerError, ""
switch businessErr.Msg {
case "ErrDockerIptablesChainUnavailable":
code, errorCode = http.StatusServiceUnavailable, "FW_DOCKER_IPTABLES_CHAIN_UNAVAILABLE"
case "ErrDockerNftablesChainUnavailable":
code, errorCode = http.StatusServiceUnavailable, "FW_DOCKER_NFTABLES_CHAIN_UNAVAILABLE"
case "ErrInvalidParams":
code, errorCode = http.StatusBadRequest, "FW_DOCKER_GUARD_INVALID"
case "ErrDockerFailed":
code, errorCode = http.StatusServiceUnavailable, "FW_DOCKER_UNAVAILABLE"
}
if errorCode != "" {
c.JSON(http.StatusOK, dto.Response{Code: code, ErrorCode: errorCode, Message: err.Error()})
c.Abort()
return return
} }
if errors.Is(err, service.ErrDockerNftablesChainUnavailable) {
helper.ErrorWithBusinessCode(c, http.StatusServiceUnavailable, "FW_DOCKER_NFTABLES_CHAIN_UNAVAILABLE", "ErrDockerNftablesChainUnavailable", err)
return
} }
if errors.Is(err, service.ErrDockerGuardInvalid) { if errors.Is(err, docker.ErrUnavailable) {
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_DOCKER_GUARD_INVALID", "ErrInvalidParams", err)
return
}
if errors.Is(err, service.ErrDockerUnavailable) {
helper.ErrorWithBusinessCode(c, http.StatusServiceUnavailable, "FW_DOCKER_UNAVAILABLE", "ErrDockerFailed", err) helper.ErrorWithBusinessCode(c, http.StatusServiceUnavailable, "FW_DOCKER_UNAVAILABLE", "ErrDockerFailed", err)
return return
} }
helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_DOCKER_GUARD_FAILED", "ErrInternalServer", err) helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_DOCKER_GUARD_FAILED", "ErrInternalServer", err)
} }
// @Tags Firewall
// @Summary List firewall rule backups
// @Param subsystem query string false "Firewall subsystem" Enums(system,forwarding,docker) default(system)
// @Success 200 {object} dto.FirewallRuleBackups
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/backups [get]
func (b *BaseApi) ListFirewallRuleBackups(c *gin.Context) {
result, err := firewallService.ListRuleBackups(c.Request.Context(), c.DefaultQuery("subsystem", "system"))
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Initialize, repair or bind one firewall address family
// @Accept json
// @Param request body dto.FirewallFamilyOperation true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/family/operate [post]
// @x-panel-log {"bodyKeys":["subsystem","family","operation"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operation] [subsystem] [family] 防火墙链","formatEN":"[operation] [subsystem] [family] firewall chains"}
func (b *BaseApi) OperateFirewallFamily(c *gin.Context) {
var request dto.FirewallFamilyOperation
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallSettingService.OperateFamily(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Update firewall IPv6 support
// @Accept json
// @Param request body dto.FirewallIPv6Operation true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/settings/ipv6 [post]
// @x-panel-log {"bodyKeys":["status"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"设置防火墙 IPv6 支持为 [status]","formatEN":"Set firewall IPv6 support to [status]"}
func (b *BaseApi) OperateFirewallIPv6(c *gin.Context) {
var request dto.FirewallIPv6Operation
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallSettingService.OperateIPv6(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
+25
View File
@@ -3,6 +3,7 @@ package helper
import ( import (
"compress/gzip" "compress/gzip"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -11,6 +12,7 @@ import (
"sync" "sync"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -21,6 +23,7 @@ import (
) )
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) { func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{ res := dto.Response{
Code: code, Code: code,
Message: "", Message: "",
@@ -31,6 +34,7 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
} }
func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) { func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{ res := dto.Response{
Code: code, Code: code,
ErrorCode: businessCode, ErrorCode: businessCode,
@@ -41,6 +45,7 @@ func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey stri
} }
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) { func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{ res := dto.Response{
Code: code, Code: code,
Data: data, Data: data,
@@ -184,3 +189,23 @@ func GetParamInt32(paramName string, c *gin.Context) (int32, error) {
intNum, _ := strconv.Atoi(idParam) intNum, _ := strconv.Atoi(idParam)
return int32(intNum), nil return int32(intNum), nil
} }
// SetOperationLogValues supplies server-resolved values to Core's operation log.
// Call before writing the response. Only pass public audit fields, never credentials.
func SetOperationLogValues(c *gin.Context, values map[string]string) {
const header = "X-Op-Resolved"
resolved := make(map[string]string)
if data, err := base64.RawURLEncoding.DecodeString(c.Writer.Header().Get(header)); err == nil {
_ = json.Unmarshal(data, &resolved)
}
if resolved == nil {
resolved = make(map[string]string)
}
for key, value := range values {
resolved[key] = value
}
data, err := json.Marshal(resolved)
if err == nil {
c.Header(header, base64.RawURLEncoding.EncodeToString(data))
}
}
+10 -23
View File
@@ -1,13 +1,9 @@
package v2 package v2
import ( import (
"sort"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/net"
) )
// @Tags Monitor // @Tags Monitor
@@ -32,14 +28,19 @@ func (b *BaseApi) LoadMonitor(c *gin.Context) {
} }
// @Tags Monitor // @Tags Monitor
// @Summary Clean monitor data // @Summary Clean host or GPU monitor data
// @Param request body dto.MonitorClean true "request"
// @Success 200 // @Success 200
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/monitor/clean [post] // @Router /hosts/monitor/clean [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"清空监控数据","formatEN":"clean monitor datas"} // @x-panel-log {"bodyKeys":["type"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"清空 [type] 监控数据","formatEN":"clean [type] monitoring data"}
func (b *BaseApi) CleanMonitor(c *gin.Context) { func (b *BaseApi) CleanMonitor(c *gin.Context) {
if err := monitorService.CleanData(); err != nil { var req dto.MonitorClean
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := monitorService.CleanData(req.Type); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
@@ -91,14 +92,7 @@ func (b *BaseApi) UpdateMonitorSetting(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /hosts/monitor/netoptions [get] // @Router /hosts/monitor/netoptions [get]
func (b *BaseApi) GetNetworkOptions(c *gin.Context) { func (b *BaseApi) GetNetworkOptions(c *gin.Context) {
netStat, _ := net.IOCounters(true) helper.SuccessWithData(c, monitorService.LoadNetworkOptions())
var options []string
options = append(options, "all")
for _, net := range netStat {
options = append(options, net.Name)
}
sort.Strings(options)
helper.SuccessWithData(c, options)
} }
// @Tags Monitor // @Tags Monitor
@@ -108,12 +102,5 @@ func (b *BaseApi) GetNetworkOptions(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /hosts/monitor/iooptions [get] // @Router /hosts/monitor/iooptions [get]
func (b *BaseApi) GetIOOptions(c *gin.Context) { func (b *BaseApi) GetIOOptions(c *gin.Context) {
diskStat, _ := disk.IOCounters() helper.SuccessWithData(c, monitorService.LoadIOOptions())
var options []string
options = append(options, "all")
for _, net := range diskStat {
options = append(options, net.Name)
}
sort.Strings(options)
helper.SuccessWithData(c, options)
} }
+45
View File
@@ -0,0 +1,45 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) LoadVLLMMonitor(c *gin.Context) {
var req dto.MonitorVLLMSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := monitorService.LoadVLLMMonitorData(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
func (b *BaseApi) LoadVLLMCurrent(c *gin.Context) {
var req dto.MonitorVLLMCurrent
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := monitorService.LoadVLLMCurrent(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
func (b *BaseApi) CleanVLLMMonitor(c *gin.Context) {
var req dto.MonitorVLLMClean
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := monitorService.CleanVLLMMonitor(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+84
View File
@@ -0,0 +1,84 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
// @Tags Dashboard
// @Summary List quick directories
// @Success 200 {array} model.QuickDirectory
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory [get]
func (b *BaseApi) ListQuickDirectories(c *gin.Context) {
items, err := dashboardService.ListQuickDirectories()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, items)
}
// @Tags Dashboard
// @Summary Create quick directory
// @Accept json
// @Param request body dto.QuickDirectoryCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory [post]
func (b *BaseApi) CreateQuickDirectory(c *gin.Context) {
var req dto.QuickDirectoryCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
item, err := dashboardService.CreateQuickDirectory(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, item)
}
// @Tags Dashboard
// @Summary Update quick directory
// @Accept json
// @Param request body dto.QuickDirectoryUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory/update [post]
func (b *BaseApi) UpdateQuickDirectory(c *gin.Context) {
var req dto.QuickDirectoryUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
item, err := dashboardService.UpdateQuickDirectory(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, item)
}
// @Tags Dashboard
// @Summary Delete quick directory
// @Accept json
// @Param request body dto.QuickDirectoryDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory/del [post]
func (b *BaseApi) DeleteQuickDirectory(c *gin.Context) {
var req dto.QuickDirectoryDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := dashboardService.DeleteQuickDirectory(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+4 -3
View File
@@ -61,13 +61,14 @@ func (b *BaseApi) CreateRuntime(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /runtimes/del [post] // @Router /runtimes/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [id]","formatEN":"Delete runtime [id]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [name]","formatEN":"Delete runtime [name]"}
func (b *BaseApi) DeleteRuntime(c *gin.Context) { func (b *BaseApi) DeleteRuntime(c *gin.Context) {
var req request.RuntimeDelete var req request.RuntimeDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
err := runtimeService.Delete(req) name, err := runtimeService.Delete(req)
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -169,7 +170,7 @@ func (b *BaseApi) GetNodePackageRunScript(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /runtimes/operate [post] // @Router /runtimes/operate [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"操作运行环境 [id]","formatEN":"Operate runtime [id]"} // @x-panel-log {"bodyKeys":["ID"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"ID","isList":false,"db":"runtimes","output_column":"name","output_value":"name"}],"formatZH":"操作运行环境 [name]","formatEN":"Operate runtime [name]"}
func (b *BaseApi) OperateRuntime(c *gin.Context) { func (b *BaseApi) OperateRuntime(c *gin.Context) {
var req request.RuntimeOperate var req request.RuntimeOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
+2
View File
@@ -83,6 +83,7 @@ func (b *BaseApi) CreateRootCert(c *gin.Context) {
} }
if err := loadCertAfterDecrypt(&req); err != nil { if err := loadCertAfterDecrypt(&req); err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return
} }
if err := sshService.CreateRootCert(req); err != nil { if err := sshService.CreateRootCert(req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
@@ -107,6 +108,7 @@ func (b *BaseApi) EditRootCert(c *gin.Context) {
} }
if err := loadCertAfterDecrypt(&req); err != nil { if err := loadCertAfterDecrypt(&req); err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return
} }
if err := sshService.EditRootCert(req); err != nil { if err := sshService.EditRootCert(req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+10
View File
@@ -243,6 +243,16 @@ func loadTerminalIdentity(c *gin.Context) (terminal.Identity, bool) {
UserID: strings.TrimSpace(c.GetHeader(terminal.HeaderUserID)), UserID: strings.TrimSpace(c.GetHeader(terminal.HeaderUserID)),
AuthSessionID: strings.TrimSpace(c.GetHeader(terminal.HeaderAuthSessionID)), AuthSessionID: strings.TrimSpace(c.GetHeader(terminal.HeaderAuthSessionID)),
} }
if value := c.GetHeader(terminal.HeaderAuthLeaseUntil); value != "" {
millis, err := strconv.ParseInt(value, 10, 64)
if err != nil || millis <= 0 {
return terminal.Identity{}, false
}
identity.AuthLeaseUntil = time.UnixMilli(millis)
if maximum := time.Now().Add(90 * time.Second); identity.AuthLeaseUntil.After(maximum) {
identity.AuthLeaseUntil = maximum
}
}
return identity, identity.Valid() return identity, identity.Valid()
} }
+10
View File
@@ -0,0 +1,10 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) TerminalCapabilities(c *gin.Context) {
helper.SuccessWithData(c, gin.H{"apiKeyLeaseVersion": 1})
}
+1 -1
View File
@@ -402,7 +402,7 @@ func (b *BaseApi) ChangeDefaultServer(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /websites/php/version [post] // @Router /websites/php/version [post]
// @x-panel-log {"bodyKeys":["websiteId"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteId","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"} // @x-panel-log {"bodyKeys":["websiteID"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteID","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
func (b *BaseApi) ChangePHPVersion(c *gin.Context) { func (b *BaseApi) ChangePHPVersion(c *gin.Context) {
var req request.WebsitePHPVersionReq var req request.WebsitePHPVersionReq
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
+64
View File
@@ -1,13 +1,16 @@
package v2 package v2
import ( import (
"io"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -176,3 +179,64 @@ func (b *BaseApi) DownloadCAFile(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name())) c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file) http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
} }
// @Tags Website CA
// @Summary Upload website CA archive
// @Accept multipart/form-data
// @Param file formData file true "ZIP containing ca.crt and ca.key"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ca/upload [post]
func (b *BaseApi) UploadWebsiteCA(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 11<<20)
if err := c.Request.ParseMultipartForm(11 << 20); err != nil {
helper.InternalServer(c, buserr.New("ErrCAUpload"))
return
}
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
file, err := c.FormFile("file")
if err != nil || file.Size > 10<<20 || !strings.HasSuffix(strings.ToLower(file.Filename), ".zip") {
helper.InternalServer(c, buserr.New("ErrCAUpload"))
return
}
stream, err := file.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
defer stream.Close()
data, err := io.ReadAll(io.LimitReader(stream, (10<<20)+1))
if err != nil {
helper.InternalServer(c, err)
return
}
if err = websiteCAService.Upload(file.Filename[:len(file.Filename)-len(".zip")], data); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website CA
// @Summary Import website CA from PEM or server files
// @Accept json
// @Param request body request.WebsiteCAImport true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ca/import [post]
func (b *BaseApi) ImportWebsiteCA(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 3<<20)
var req request.WebsiteCAImport
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteCAService.Import(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+4 -1
View File
@@ -9,7 +9,8 @@ type AgentCreateReq struct {
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"` WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
BridgePort int `json:"bridgePort"` BridgePort int `json:"bridgePort"`
AllowedOrigins []string `json:"allowedOrigins"` AllowedOrigins []string `json:"allowedOrigins"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent"` AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent deepseek-harness octop"`
AccessHost string `json:"accessHost"`
Model string `json:"model"` Model string `json:"model"`
AccountID uint `json:"accountId"` AccountID uint `json:"accountId"`
Token string `json:"token"` Token string `json:"token"`
@@ -110,6 +111,7 @@ type AgentBatchOperateResult struct {
} }
type AgentItem struct { type AgentItem struct {
AccessHost string `json:"accessHost"`
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Remark string `json:"remark"` Remark string `json:"remark"`
@@ -384,6 +386,7 @@ type AgentAccountProviderCountReq struct {
} }
type AgentAccountInfo struct { type AgentAccountInfo struct {
AppInstallID uint `json:"appInstallId"`
ID uint `json:"id"` ID uint `json:"id"`
MasterAccountID uint `json:"masterAccountId"` MasterAccountID uint `json:"masterAccountId"`
Provider string `json:"provider"` Provider string `json:"provider"`
+6 -5
View File
@@ -14,16 +14,17 @@ type OllamaModelInfo struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type OllamaModelDropList struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type OllamaModelName struct { type OllamaModelName struct {
Name string `json:"name"` Name string `json:"name"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
} }
type OllamaModelAccountSync struct {
ID uint `json:"id" validate:"required"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"required,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
}
type OllamaBindDomain struct { type OllamaBindDomain struct {
Domain string `json:"domain" validate:"required"` Domain string `json:"domain" validate:"required"`
AppInstallID uint `json:"appInstallID" validate:"required"` AppInstallID uint `json:"appInstallID" validate:"required"`
+13
View File
@@ -0,0 +1,13 @@
package dto
type AIBenchmarkSource struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Status string `json:"status"`
BaseURL string `json:"baseUrl"`
Endpoint string `json:"endpoint"`
Models []string `json:"models"`
Tokenizer string `json:"tokenizer"`
Message string `json:"message"`
}
+2
View File
@@ -21,6 +21,7 @@ type AlertBase struct {
} }
type PushAlert struct { type PushAlert struct {
Result string `json:"result,omitempty"`
TaskName string `json:"taskName"` TaskName string `json:"taskName"`
AlertType string `json:"alertType"` AlertType string `json:"alertType"`
EntryID uint `json:"entryID"` EntryID uint `json:"entryID"`
@@ -53,6 +54,7 @@ type AlertDTO struct {
Method string `json:"method"` Method string `json:"method"`
Title string `json:"title"` Title string `json:"title"`
Project string `json:"project"` Project string `json:"project"`
TaskName string `json:"taskName,omitempty"`
Status string `json:"status"` Status string `json:"status"`
SendCount uint `json:"sendCount"` SendCount uint `json:"sendCount"`
AdvancedParams string `json:"advancedParams"` AdvancedParams string `json:"advancedParams"`
+1
View File
@@ -172,6 +172,7 @@ type ContainerItemStats struct {
} }
type ContainerListStats struct { type ContainerListStats struct {
ContainerID string `json:"containerID"` ContainerID string `json:"containerID"`
ContainerName string `json:"containerName"`
CPUTotalUsage uint64 `json:"cpuTotalUsage"` CPUTotalUsage uint64 `json:"cpuTotalUsage"`
SystemUsage uint64 `json:"systemUsage"` SystemUsage uint64 `json:"systemUsage"`
+3
View File
@@ -54,6 +54,7 @@ type CronjobOperate struct {
AlertCount uint `json:"alertCount"` AlertCount uint `json:"alertCount"`
AlertTitle string `json:"alertTitle"` AlertTitle string `json:"alertTitle"`
AlertMethod string `json:"alertMethod"` AlertMethod string `json:"alertMethod"`
AlertTriggerMode string `json:"alertTriggerMode" validate:"omitempty,oneof=failed success both"`
CleanLogConfig CleanLogConfig
} }
@@ -127,6 +128,7 @@ type CronjobInfo struct {
Args string `json:"args"` Args string `json:"args"`
AlertCount uint `json:"alertCount"` AlertCount uint `json:"alertCount"`
AlertTriggerMode string `json:"alertTriggerMode"`
} }
type CronjobImport struct { type CronjobImport struct {
@@ -172,6 +174,7 @@ type CronjobTrans struct {
AlertCount uint `json:"alertCount"` AlertCount uint `json:"alertCount"`
AlertTitle string `json:"alertTitle"` AlertTitle string `json:"alertTitle"`
AlertMethod string `json:"alertMethod"` AlertMethod string `json:"alertMethod"`
AlertTriggerMode string `json:"alertTriggerMode" validate:"omitempty,oneof=failed success both"`
} }
type TransHelper struct { type TransHelper struct {
Name string `json:"name"` Name string `json:"name"`
+21 -1
View File
@@ -31,7 +31,27 @@ type DashboardBase struct {
} }
type ChangeQuicks struct { type ChangeQuicks struct {
Quicks []QuickJump `json:"quicks"` Quicks []QuickVisibility `json:"quicks" validate:"required,dive"`
}
type QuickVisibility struct {
ID uint `json:"id" validate:"required"`
IsShow bool `json:"isShow"`
}
type QuickDirectoryCreate struct {
Path string `json:"path" validate:"required"`
Alias string `json:"alias"`
}
type QuickDirectoryDelete struct {
ID uint `json:"id" validate:"required"`
Legacy bool `json:"legacy"`
}
type QuickDirectoryUpdate struct {
QuickDirectoryDelete
QuickDirectoryCreate
} }
type QuickJump struct { type QuickJump struct {
+4
View File
@@ -22,6 +22,10 @@ type DBBaseInfo struct {
Port int64 `json:"port"` Port int64 `json:"port"`
} }
type RedisCliInstall struct {
TaskID string `json:"taskID" validate:"omitempty,uuid"`
}
// mysql // mysql
type MysqlDBSearch struct { type MysqlDBSearch struct {
PageInfo PageInfo
+65 -96
View File
@@ -2,11 +2,13 @@ package dto
import ( import (
"github.com/1Panel-dev/1Panel/agent/utils/firewall" "github.com/1Panel-dev/1Panel/agent/utils/firewall"
dockerfirewall "github.com/1Panel-dev/1Panel/agent/utils/firewall/docker_guard"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter" "github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
firewallsync "github.com/1Panel-dev/1Panel/agent/utils/firewall/sync" "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
) )
type FirewallSubsystemStatus struct { type FirewallSubsystemStatus struct {
IPv6Enabled bool `json:"ipv6Enabled"`
Name string `json:"name"` Name string `json:"name"`
Backend string `json:"backend"` Backend string `json:"backend"`
ConflictBackend string `json:"conflictBackend,omitempty"` ConflictBackend string `json:"conflictBackend,omitempty"`
@@ -18,13 +20,13 @@ type FirewallSubsystemStatus struct {
PingStatus string `json:"pingStatus"` PingStatus string `json:"pingStatus"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
SyncError string `json:"syncError,omitempty"`
LifecycleTaskID string `json:"lifecycleTaskID,omitempty"` LifecycleTaskID string `json:"lifecycleTaskID,omitempty"`
IPv4 FirewallBackendFamilyStatus `json:"ipv4"` IPv4 FirewallBackendFamilyStatus `json:"ipv4"`
IPv6 FirewallBackendFamilyStatus `json:"ipv6"` IPv6 FirewallBackendFamilyStatus `json:"ipv6"`
} }
type FirewallLifecycleOperation struct { type FirewallLifecycleOperation struct {
BackupFile string `json:"backupFile,omitempty" validate:"omitempty,max=255"`
Operation string `json:"operation" validate:"required,oneof=start stop restart disableBanPing enableBanPing"` Operation string `json:"operation" validate:"required,oneof=start stop restart disableBanPing enableBanPing"`
WithDockerRestart bool `json:"withDockerRestart"` WithDockerRestart bool `json:"withDockerRestart"`
} }
@@ -49,10 +51,12 @@ type FirewallBackendOption struct {
} }
type FirewallBackendFamilyStatus struct { type FirewallBackendFamilyStatus struct {
Partial bool `json:"partial"`
Available bool `json:"available"` Available bool `json:"available"`
Initialized bool `json:"initialized"` Initialized bool `json:"initialized"`
Bound bool `json:"bound"` Bound bool `json:"bound"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
RAInterfaces []string `json:"raInterfaces,omitempty"`
} }
type FirewallBackendGroup struct { type FirewallBackendGroup struct {
@@ -62,6 +66,7 @@ type FirewallBackendGroup struct {
} }
type FirewallSettings struct { type FirewallSettings struct {
IPv6Enabled bool `json:"ipv6Enabled"`
System FirewallBackendGroup `json:"system"` System FirewallBackendGroup `json:"system"`
Forwarding FirewallBackendGroup `json:"forwarding"` Forwarding FirewallBackendGroup `json:"forwarding"`
Docker FirewallBackendGroup `json:"docker"` Docker FirewallBackendGroup `json:"docker"`
@@ -90,6 +95,17 @@ type FirewallBackendOperation struct {
Operation string `json:"operation" validate:"required,oneof=select initialize cleanup"` Operation string `json:"operation" validate:"required,oneof=select initialize cleanup"`
} }
type FirewallIPv6Operation struct {
Status string `json:"status" validate:"required,oneof=Enable Disable"`
}
type FirewallFamilyOperation struct {
Subsystem string `json:"subsystem" validate:"required,oneof=system forwarding docker"`
Backend string `json:"backend" validate:"required,oneof=iptables nftables"`
Family string `json:"family" validate:"required,oneof=ipv4 ipv6"`
Operation string `json:"operation" validate:"required,oneof=initialize repair bind"`
}
type FilterChainOperation struct { type FilterChainOperation struct {
Name string `json:"name" validate:"required,eq=1PANEL_BASIC"` Name string `json:"name" validate:"required,eq=1PANEL_BASIC"`
Operate string `json:"operate" validate:"required,oneof=init-base bind-base unbind-base"` Operate string `json:"operate" validate:"required,oneof=init-base bind-base unbind-base"`
@@ -102,6 +118,7 @@ type FilterChainOperationResponse struct {
} }
type FirewallInitializationTask struct { type FirewallInitializationTask struct {
BackupFile string `json:"backupFile,omitempty" validate:"omitempty,max=255"`
TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"` TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"`
} }
@@ -112,23 +129,36 @@ type FirewallRuleInventoryResponse struct {
IPv6Range filter.PositionRange `json:"ipv6Range"` IPv6Range filter.PositionRange `json:"ipv6Range"`
Total int64 `json:"total"` Total int64 `json:"total"`
AllTotal int64 `json:"allTotal"` AllTotal int64 `json:"allTotal"`
ManagedTotal int64 `json:"managedTotal"`
Items []filter.InventoryItem `json:"items"` Items []filter.InventoryItem `json:"items"`
Notices []filter.ScopeNotice `json:"notices,omitempty"` Notices []filter.ScopeNotice `json:"notices,omitempty"`
} }
type FirewallRuleBackup struct {
Name string `json:"name"`
Provider filter.Provider `json:"provider"`
RuleCount int `json:"ruleCount"`
ModifiedAt int64 `json:"modifiedAt"`
}
type FirewallRuleBackups struct {
Directory string `json:"directory"`
Files []FirewallRuleBackup `json:"files"`
}
type FirewallRuleResetResponse struct { type FirewallRuleResetResponse struct {
BackupPath string `json:"backupPath"`
Removed int `json:"removed"` Removed int `json:"removed"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
} }
type FirewallRuleReset struct { type FirewallRuleReset struct {
Subsystem string `json:"subsystem,omitempty" validate:"omitempty,oneof=system forwarding docker"`
Backup *bool `json:"backup,omitempty" default:"true"`
Provider filter.Provider `json:"provider,omitempty" validate:"omitempty,oneof=firewalld ufw iptables nftables"` Provider filter.Provider `json:"provider,omitempty" validate:"omitempty,oneof=firewalld ufw iptables nftables"`
WithDockerRestart bool `json:"withDockerRestart"` WithDockerRestart bool `json:"withDockerRestart"`
} }
type FirewallRuleInventory struct { type FirewallRuleInventory struct {
Refresh bool `json:"refresh,omitempty"`
PageInfo PageInfo
Scope filter.Scope `json:"scope,omitempty"` Scope filter.Scope `json:"scope,omitempty"`
Scopes []filter.Scope `json:"scopes,omitempty" validate:"max=16"` Scopes []filter.Scope `json:"scopes,omitempty" validate:"max=16"`
@@ -136,7 +166,6 @@ type FirewallRuleInventory struct {
Info string `json:"info"` Info string `json:"info"`
Families []filter.Family `json:"families,omitempty" validate:"omitempty,dive,oneof=ipv4 ipv6"` Families []filter.Family `json:"families,omitempty" validate:"omitempty,dive,oneof=ipv4 ipv6"`
Actions []string `json:"actions,omitempty" validate:"omitempty,dive,oneof=accept deny"` Actions []string `json:"actions,omitempty" validate:"omitempty,dive,oneof=accept deny"`
States []filter.InventoryState `json:"states,omitempty" validate:"omitempty,dive,oneof=managed adopted external drifted protected"`
ExcludeChains []string `json:"excludeChains,omitempty" validate:"omitempty,dive,oneof=1PANEL_BASIC_BEFORE 1PANEL_BASIC 1PANEL_BASIC_AFTER"` ExcludeChains []string `json:"excludeChains,omitempty" validate:"omitempty,dive,oneof=1PANEL_BASIC_BEFORE 1PANEL_BASIC 1PANEL_BASIC_AFTER"`
} }
@@ -148,6 +177,7 @@ type FirewallNativeDetail struct {
} }
type DockerPortGuardBase struct { type DockerPortGuardBase struct {
IPv6Enabled bool `json:"ipv6Enabled"`
Name string `json:"name"` Name string `json:"name"`
Version string `json:"version"` Version string `json:"version"`
IsExist bool `json:"isExist"` IsExist bool `json:"isExist"`
@@ -160,6 +190,7 @@ type DockerPortGuardBase struct {
} }
type DockerPortGuardFamilyStatus struct { type DockerPortGuardFamilyStatus struct {
Partial bool `json:"partial"`
State string `json:"state"` State string `json:"state"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Initialized bool `json:"initialized"` Initialized bool `json:"initialized"`
@@ -180,11 +211,8 @@ type DockerPortGuardEndpoint struct {
Application string `json:"application,omitempty"` Application string `json:"application,omitempty"`
PolicyUUID string `json:"policyUUID,omitempty"` PolicyUUID string `json:"policyUUID,omitempty"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
NativeAction string `json:"nativeAction,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Sources []string `json:"sources"` Sources []string `json:"sources"`
Effective bool `json:"effective"` Effective bool `json:"effective"`
Description string `json:"description,omitempty"`
TrafficPath string `json:"trafficPath"` TrafficPath string `json:"trafficPath"`
ManagementTarget string `json:"managementTarget"` ManagementTarget string `json:"managementTarget"`
ManagementReason string `json:"managementReason,omitempty"` ManagementReason string `json:"managementReason,omitempty"`
@@ -221,6 +249,7 @@ type DockerPortGuardEndpointIdentity struct {
type DockerPortGuardPolicyBatch struct { type DockerPortGuardPolicyBatch struct {
Policies []DockerPortGuardPolicy `json:"policies" validate:"required,min=1,dive"` Policies []DockerPortGuardPolicy `json:"policies" validate:"required,min=1,dive"`
Import bool `json:"import"`
} }
type DockerPortGuardPolicyBatchDelete struct { type DockerPortGuardPolicyBatchDelete struct {
@@ -229,29 +258,27 @@ type DockerPortGuardPolicyBatchDelete struct {
type DockerPortGuardPolicy struct { type DockerPortGuardPolicy struct {
DockerPortGuardEndpointIdentity DockerPortGuardEndpointIdentity
Mode string `json:"mode" validate:"required,oneof=deny_sources allow_sources deny_all"` Mode string `json:"mode" validate:"required,oneof=deny_sources allow_sources deny_all accept_sources accept_all"`
Sources []string `json:"sources" validate:"dive,required,max=64"` Sources []string `json:"sources" validate:"dive,required,max=64"`
Description string `json:"description" validate:"max=256"`
} }
type DockerPortGuardOperation struct { type DockerPortGuardOperation struct {
BackupFile string `json:"backupFile,omitempty" validate:"omitempty,max=255"`
Operation string `json:"operation" validate:"required,oneof=initialize bind unbind"` Operation string `json:"operation" validate:"required,oneof=initialize bind unbind"`
TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"` TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"`
} }
type FirewallRuleAdopt struct {
Scope filter.Scope `json:"scope" validate:"required"`
InstanceKey string `json:"instanceKey" validate:"required,max=128"`
}
type FirewallRuleCreateItem struct { type FirewallRuleCreateItem struct {
Raw string `json:"raw,omitempty"`
ParseStatus filter.ParseStatus `json:"parseStatus,omitempty"`
Rule filter.FirewallRule `json:"rule" validate:"required"` Rule filter.FirewallRule `json:"rule" validate:"required"`
SourceKind string `json:"sourceKind" validate:"omitempty,oneof=user imported"` SourceKind string `json:"sourceKind" validate:"omitempty,oneof=user imported"`
SourceID string `json:"sourceID"`
} }
type FirewallRuleCreate struct { type FirewallRuleCreate struct {
Items []FirewallRuleCreateItem `json:"items" validate:"required,min=1,dive"` BackupFile string `json:"backupFile,omitempty" validate:"omitempty,max=255"`
Initialize bool `json:"initialize"`
Items []FirewallRuleCreateItem `json:"items" validate:"dive"`
} }
type FirewallRuleCreateResponse struct { type FirewallRuleCreateResponse struct {
@@ -270,66 +297,13 @@ type FirewallRuleCreateFailure struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type FirewallRuleSyncRequest struct {
Subsystem string `json:"subsystem" validate:"omitempty,oneof=system forwarding docker"`
SourceProvider filter.Provider `json:"sourceProvider,omitempty" validate:"omitempty,oneof=firewalld ufw iptables nftables"`
TargetProvider filter.Provider `json:"targetProvider" validate:"required,oneof=firewalld ufw iptables nftables"`
ResetSource bool `json:"resetSource"`
TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"`
}
type FirewallRuleSyncItem struct {
SourceUUID string `json:"sourceUUID"`
Rule *filter.FirewallRule `json:"rule,omitempty"`
ForwardRule *ForwardRule `json:"forwardRule,omitempty"`
DockerRule *DockerPortGuardEndpoint `json:"dockerRule,omitempty"`
Status firewallsync.Status `json:"status"`
ReasonCode firewallsync.ReasonCode `json:"reasonCode,omitempty"`
Reason string `json:"reason,omitempty"`
}
type FirewallRuleSyncPreview struct {
Subsystem string `json:"subsystem"`
SourceProvider filter.Provider `json:"sourceProvider,omitempty"`
TargetProvider filter.Provider `json:"targetProvider"`
Total int `json:"total"`
Ready int `json:"ready"`
Existing int `json:"existing"`
Removed int `json:"removed"`
Blocked int `json:"blocked"`
Items []FirewallRuleSyncItem `json:"items"`
}
type FirewallRuleSyncResult struct {
Subsystem string `json:"subsystem"`
SourceProvider filter.Provider `json:"sourceProvider,omitempty"`
TargetProvider filter.Provider `json:"targetProvider"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Skipped int `json:"skipped"`
Removed int `json:"removed"`
Failed int `json:"failed"`
Errors []FirewallRuleSyncFailure `json:"errors,omitempty"`
TaskID string `json:"taskID,omitempty"`
Queued bool `json:"queued,omitempty"`
}
type FirewallRuleSyncTask struct {
TaskID string `json:"taskID,omitempty"`
Executing bool `json:"executing"`
}
type FirewallRuleSyncFailure struct {
SourceUUID string `json:"sourceUUID"`
Rule *filter.FirewallRule `json:"rule,omitempty"`
ForwardRule *ForwardRule `json:"forwardRule,omitempty"`
DockerRule *DockerPortGuardEndpoint `json:"dockerRule,omitempty"`
Error string `json:"error"`
}
type FirewallRuleDelete struct { type FirewallRuleDelete struct {
UUIDs []string `json:"uuids" validate:"omitempty,dive,required,max=64"` Targets []FirewallRuleDeleteItem `json:"targets" validate:"required,min=1,dive"`
BeforeRules []FirewallRuleDeleteTarget `json:"beforeRules,omitempty" validate:"omitempty,dive"` }
type FirewallRuleDeleteItem struct {
FirewallRuleDeleteTarget
Observed filter.ObservedRule `json:"observed" validate:"required"`
} }
type FirewallRuleDeleteTarget struct { type FirewallRuleDeleteTarget struct {
@@ -347,12 +321,12 @@ type FirewallRuleDeleteResponse struct {
type FirewallRuleDeleteFailure struct { type FirewallRuleDeleteFailure struct {
Index int `json:"index"` Index int `json:"index"`
UUID string `json:"uuid"` InstanceKey string `json:"instanceKey"`
Error string `json:"error"` Error string `json:"error"`
} }
type FirewallRuleUpdate struct { type FirewallRuleUpdate struct {
UUID string `json:"uuid" validate:"required,max=64"` FirewallRuleDeleteTarget
Rule *filter.FirewallRule `json:"rule,omitempty" validate:"required_without_all=Description OrderIndex Priority,excluded_with=Description OrderIndex Priority"` Rule *filter.FirewallRule `json:"rule,omitempty" validate:"required_without_all=Description OrderIndex Priority,excluded_with=Description OrderIndex Priority"`
Description *string `json:"description,omitempty" validate:"excluded_with=Rule"` Description *string `json:"description,omitempty" validate:"excluded_with=Rule"`
OrderIndex *int64 `json:"orderIndex,omitempty" validate:"excluded_with=Rule Priority"` OrderIndex *int64 `json:"orderIndex,omitempty" validate:"excluded_with=Rule Priority"`
@@ -360,26 +334,21 @@ type FirewallRuleUpdate struct {
} }
type FirewallRuleReorder struct { type FirewallRuleReorder struct {
UUID string `json:"uuid" validate:"required,max=64"` FirewallRuleDeleteTarget
TargetPosition *int64 `json:"targetPosition"` TargetPosition *int64 `json:"targetPosition"`
Priority *int `json:"priority"` Priority *int `json:"priority"`
} }
func (p *FirewallRuleSyncPreview) Add(item FirewallRuleSyncItem) { type FirewallRuleExportItem struct {
p.Items = append(p.Items, item) filter.FirewallRule
switch item.Status { Raw string `json:"raw,omitempty"`
case firewallsync.StatusReady: ParseStatus filter.ParseStatus `json:"parseStatus,omitempty"`
p.Ready++ }
p.Total++
case firewallsync.StatusExisting: type FirewallSubsystemBackup struct {
p.Existing++ Families []string `json:"families,omitempty"`
p.Total++ Subsystem string `json:"subsystem"`
case firewallsync.StatusRemove: Provider filter.Provider `json:"provider"`
p.Removed++ Forwarding []forwarding.Rule `json:"forwarding"`
case firewallsync.StatusBlocked: Docker *dockerfirewall.PolicyInventory `json:"docker,omitempty"`
p.Blocked++
if item.ReasonCode != firewallsync.ReasonReadOnlyRule {
p.Total++
}
}
} }
+1 -5
View File
@@ -24,14 +24,10 @@ type ForwardRule struct {
UsedStatus string `json:"usedStatus"` UsedStatus string `json:"usedStatus"`
Description string `json:"description"` Description string `json:"description"`
IsDesired bool `json:"isDesired"`
IsRuntime bool `json:"isRuntime"`
SyncStatus string `json:"syncStatus"`
} }
type ForwardRuleOperate struct { type ForwardRuleOperate struct {
ForceDelete bool `json:"forceDelete"` Import bool `json:"import"`
Rules []ForwardRuleOperation `json:"rules" validate:"required,min=1,dive"` Rules []ForwardRuleOperation `json:"rules" validate:"required,min=1,dive"`
} }
+89 -13
View File
@@ -1,6 +1,11 @@
package dto package dto
import "time" import (
"encoding/json"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
)
type MonitorSearch struct { type MonitorSearch struct {
Param string `json:"param" validate:"required,oneof=all cpu memory load io network"` Param string `json:"param" validate:"required,oneof=all cpu memory load io network"`
@@ -26,6 +31,12 @@ type Process struct {
} }
type MonitorSetting struct { type MonitorSetting struct {
GPUMonitorStatus string `json:"gpuMonitorStatus"`
GPUMonitorStoreDays string `json:"gpuMonitorStoreDays"`
GPUMonitorInterval string `json:"gpuMonitorInterval"`
VLLMMonitorStatus string `json:"vllmMonitorStatus"`
VLLMMonitorStoreDays string `json:"vllmMonitorStoreDays"`
VLLMMonitorInterval string `json:"vllmMonitorInterval"`
MonitorStatus string `json:"monitorStatus"` MonitorStatus string `json:"monitorStatus"`
MonitorStoreDays string `json:"monitorStoreDays"` MonitorStoreDays string `json:"monitorStoreDays"`
MonitorInterval string `json:"monitorInterval"` MonitorInterval string `json:"monitorInterval"`
@@ -34,16 +45,19 @@ type MonitorSetting struct {
} }
type MonitorSettingUpdate struct { type MonitorSettingUpdate struct {
Key string `json:"key" validate:"required,oneof=MonitorStatus MonitorStoreDays MonitorInterval DefaultNetwork DefaultIO"` Key string `json:"key" validate:"required,oneof=MonitorStatus MonitorStoreDays MonitorInterval GPUMonitorStatus GPUMonitorStoreDays GPUMonitorInterval VLLMMonitorStatus VLLMMonitorStoreDays VLLMMonitorInterval DefaultNetwork DefaultIO"`
Value string `json:"value"` Value string `json:"value"`
} }
type MonitorGPUOptions struct { type MonitorGPUOptions struct {
Supported bool `json:"supported"`
GPUType string `json:"gpuType"` GPUType string `json:"gpuType"`
ChartHide []GPUChartHide `json:"chartHide"` ChartHide []GPUChartHide `json:"chartHide"`
Options []string `json:"options"` Options []string `json:"options"`
} }
type GPUChartHide struct { type GPUChartHide struct {
DeviceID string `json:"deviceID"`
Legacy bool `json:"legacy"`
ProductName string `json:"productName"` ProductName string `json:"productName"`
Type string `json:"type"` Type string `json:"type"`
Process bool `json:"process"` Process bool `json:"process"`
@@ -55,23 +69,54 @@ type GPUChartHide struct {
Speed bool `json:"speed"` Speed bool `json:"speed"`
} }
type MonitorGPUSearch struct { type MonitorGPUSearch struct {
Aggregation string `json:"aggregation" validate:"omitempty,oneof=avg max"`
DeviceID string `json:"deviceID"`
Legacy bool `json:"legacy"`
ProductName string `json:"productName"` ProductName string `json:"productName"`
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"` EndTime time.Time `json:"endTime"`
} }
type MonitorGPUData struct { type MonitorGPUData struct {
Date []time.Time `json:"date"` MemoryActivity []*float64 `json:"memoryActivity"`
GPUValue []float64 `json:"gpuValue"` EncoderUtil []*float64 `json:"encoderUtil"`
TemperatureValue []float64 `json:"temperatureValue"` DecoderUtil []*float64 `json:"decoderUtil"`
PowerTotal []float64 `json:"powerTotal"` JPEGUtil []*float64 `json:"jpegUtil"`
PowerUsed []float64 `json:"powerUsed"` OFAUtil []*float64 `json:"ofaUtil"`
PowerPercent []float64 `json:"powerPercent"` MediaUtil []*float64 `json:"mediaUtil"`
MemoryTotal []float64 `json:"memoryTotal"` ComputeUtil []*float64 `json:"computeUtil"`
MemoryUsed []float64 `json:"memoryUsed"` CopyUtil []*float64 `json:"copyUtil"`
MemoryPercent []float64 `json:"memoryPercent"` HotspotTemperature []*float64 `json:"hotspotTemperature"`
SpeedValue []int `json:"speedValue"` FanRPM []*float64 `json:"fanRPM"`
AICPUUtil []*float64 `json:"aiCPUUtil"`
CtrlCPUUtil []*float64 `json:"ctrlCPUUtil"`
DDRUsed []*float64 `json:"ddrUsed"`
DDRTotal []*float64 `json:"ddrTotal"`
HBMUsed []*float64 `json:"hbmUsed"`
HBMTotal []*float64 `json:"hbmTotal"`
DDRBandwidth []*float64 `json:"ddrBandwidth"`
HBMBandwidth []*float64 `json:"hbmBandwidth"`
MemoryBandwidth []*float64 `json:"memoryBandwidth"`
MediaFrequency []*float64 `json:"mediaFrequency"`
HugepagesUsed []*float64 `json:"hugepagesUsed"`
HugepagesTotal []*float64 `json:"hugepagesTotal"`
ProcessCount []int `json:"processCount"` BucketSeconds int64 `json:"bucketSeconds"`
SampleCount int64 `json:"sampleCount"`
MemoryTemperatureValue []*float64 `json:"memoryTemperatureValue"`
FrequencyValue []*float64 `json:"frequencyValue"`
MemoryFrequencyValue []*float64 `json:"memoryFrequencyValue"`
Date []time.Time `json:"date"`
GPUValue []*float64 `json:"gpuValue"`
TemperatureValue []*float64 `json:"temperatureValue"`
PowerTotal []*float64 `json:"powerTotal"`
PowerUsed []*float64 `json:"powerUsed"`
PowerPercent []*float64 `json:"powerPercent"`
MemoryTotal []*float64 `json:"memoryTotal"`
MemoryUsed []*float64 `json:"memoryUsed"`
MemoryPercent []*float64 `json:"memoryPercent"`
SpeedValue []*float64 `json:"speedValue"`
ProcessCount []*float64 `json:"processCount"`
GPUProcesses [][]GPUProcess `json:"gpuProcesses"` GPUProcesses [][]GPUProcess `json:"gpuProcesses"`
} }
@@ -81,3 +126,34 @@ type GPUProcess struct {
ProcessName string `json:"processName"` ProcessName string `json:"processName"`
UsedMemory string `json:"usedMemory"` UsedMemory string `json:"usedMemory"`
} }
type MonitorVLLMSearch struct {
Group string `json:"group" validate:"omitempty,oneof=overview latency requests"`
AppInstallID uint `json:"appInstallID" validate:"required"`
StartTime time.Time `json:"startTime" validate:"required"`
EndTime time.Time `json:"endTime" validate:"required"`
Aggregation string `json:"aggregation" validate:"omitempty,oneof=avg max"`
}
type MonitorVLLMData struct {
SampleCount int64 `json:"sampleCount"`
BucketSeconds int64 `json:"bucketSeconds"`
Points []model.MonitorVLLM `json:"points"`
}
type MonitorVLLMCurrent struct {
AppInstallID uint `json:"appInstallID" validate:"required"`
}
type MonitorVLLMCurrentData struct {
model.MonitorVLLM
Metrics map[string]json.RawMessage `json:"metrics,omitempty"`
}
type MonitorVLLMClean struct {
AppInstallID uint `json:"appInstallID" validate:"required"`
}
type MonitorClean struct {
Type string `json:"type" validate:"required,oneof=host gpu"`
}
+9
View File
@@ -23,6 +23,12 @@ type AppInstallCreate struct {
Services map[string]string `json:"services"` Services map[string]string `json:"services"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
MCPPreserveWebsiteDir bool `json:"-"`
AppContainerConfig AppContainerConfig
NodePushConfig NodePushConfig
} }
@@ -122,6 +128,9 @@ type AppInstallDelete struct {
} }
type AppInstalledUpdate struct { type AppInstalledUpdate struct {
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
InstallId uint `json:"installId" validate:"required"` InstallId uint `json:"installId" validate:"required"`
Params map[string]interface{} `json:"params" validate:"required"` Params map[string]interface{} `json:"params" validate:"required"`
TaskID string `json:"-"` TaskID string `json:"-"`
+9
View File
@@ -182,3 +182,12 @@ type WebsiteSSLFileUpload struct {
PushNode bool `json:"pushNode"` PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"` Nodes string `json:"nodes"`
} }
type WebsiteCAImport struct {
Name string `json:"name" validate:"required,max=128"`
Type string `json:"type" validate:"required,oneof=paste local"`
Certificate string `json:"certificate" validate:"required_if=Type paste"`
PrivateKey string `json:"privateKey" validate:"required_if=Type paste"`
CertificatePath string `json:"certificatePath" validate:"required_if=Type local"`
PrivateKeyPath string `json:"privateKeyPath" validate:"required_if=Type local"`
}
+8
View File
@@ -50,6 +50,8 @@ type TagDTO struct {
} }
type AppInstalledCheck struct { type AppInstalledCheck struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
IsExist bool `json:"isExist"` IsExist bool `json:"isExist"`
Name string `json:"name"` Name string `json:"name"`
App string `json:"app"` App string `json:"app"`
@@ -184,6 +186,12 @@ type AppParam struct {
} }
type AppConfig struct { type AppConfig struct {
AppKey string `json:"appKey"`
Name string `json:"name"`
HttpPort int `json:"httpPort"`
SyncModelAccount bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
Params []AppParam `json:"params"` Params []AppParam `json:"params"`
RawCompose string `json:"rawCompose"` RawCompose string `json:"rawCompose"`
request.AppContainerConfig request.AppContainerConfig
+1 -1
View File
@@ -25,7 +25,7 @@ type RootCertOperate struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Mode string `json:"mode"` Mode string `json:"mode"`
EncryptionMode string `json:"encryptionMode" validate:"required,oneof=rsa ed25519 ecdsa dsa"` EncryptionMode string `json:"encryptionMode"`
PassPhrase string `json:"passPhrase"` PassPhrase string `json:"passPhrase"`
PublicKey string `json:"publicKey"` PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKey"` PrivateKey string `json:"privateKey"`
+1
View File
@@ -2,6 +2,7 @@ package model
type AgentAccount struct { type AgentAccount struct {
BaseModel BaseModel
AppInstallID uint `json:"appInstallId"`
Provider string `json:"provider"` Provider string `json:"provider"`
Name string `json:"name"` Name string `json:"name"`
APIKey string `json:"apiKey"` APIKey string `json:"apiKey"`
+4
View File
@@ -28,6 +28,10 @@ type AppInstall struct {
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder" gorm:"default:0"` SortOrder int `json:"sortOrder" gorm:"default:0"`
SyncModelAccount bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
App App `json:"app" gorm:"-:migration"` App App `json:"app" gorm:"-:migration"`
} }
+7
View File
@@ -5,6 +5,13 @@ type AppLauncher struct {
Key string `json:"key"` Key string `json:"key"`
} }
type QuickDirectory struct {
BaseModel
Path string `json:"path"`
Alias string `json:"alias"`
Legacy bool `json:"legacy" gorm:"-"`
}
type QuickJump struct { type QuickJump struct {
BaseModel BaseModel
Name string `json:"name"` Name string `json:"name"`
-222
View File
@@ -1,222 +0,0 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
)
const FirewallRuleSequenceStep int64 = 1 << 32
type DockerPortGuardPolicy struct {
BaseModel
UUID string `gorm:"size:64;not null;uniqueIndex" json:"uuid"`
ReadOnly bool `gorm:"not null;default:false;uniqueIndex:idx_docker_port_guard_endpoint" json:"-"`
Family string `gorm:"size:16;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"family"`
HostIP string `gorm:"size:64;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostIP"`
HostPort uint16 `gorm:"not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostPort"`
Protocol string `gorm:"size:8;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"protocol"`
Mode string `gorm:"size:32;not null" json:"mode"`
Sources string `gorm:"type:text" json:"-"`
Description string `gorm:"type:text" json:"description"`
NativeAction string `gorm:"size:32;not null;default:''" json:"-"`
NativeRules string `gorm:"type:text" json:"-"`
Sequence int64 `gorm:"not null;default:0" json:"-"`
}
type ForwardingRule struct {
BaseModel
Family string `gorm:"size:16;not null;uniqueIndex:idx_forwarding_rule_identity" json:"family"`
Protocol string `gorm:"size:8;not null;uniqueIndex:idx_forwarding_rule_identity" json:"protocol"`
Port string `gorm:"size:32;not null;uniqueIndex:idx_forwarding_rule_identity" json:"port"`
TargetIP string `gorm:"size:64;not null;uniqueIndex:idx_forwarding_rule_identity" json:"targetIP"`
TargetPort string `gorm:"size:32;not null;uniqueIndex:idx_forwarding_rule_identity" json:"targetPort"`
Interface string `gorm:"size:32;not null;default:'';uniqueIndex:idx_forwarding_rule_identity" json:"interface"`
}
type FirewallRule struct {
UUID string `gorm:"size:64;primaryKey" json:"uuid"`
Family string `gorm:"size:16;not null" json:"family"`
Protocol string `gorm:"size:32;not null" json:"protocol"`
SourceAddress string `gorm:"size:255" json:"sourceAddress"`
SourcePort string `gorm:"size:64" json:"sourcePort"`
DestinationAddress string `gorm:"size:255" json:"destinationAddress"`
DestinationPort string `gorm:"size:64" json:"destinationPort"`
Interface string `gorm:"size:128" json:"interface"`
ConnectionStates string `gorm:"type:text" json:"connectionStates"`
Action string `gorm:"size:32;not null" json:"action"`
Description string `gorm:"type:text" json:"description"`
CompatibilityError string `gorm:"type:text" json:"compatibilityError,omitempty"`
Priority *int `json:"priority,omitempty"`
Sequence *int64 `gorm:"index" json:"sequence,omitempty"`
Origin string `gorm:"size:32;not null" json:"origin"`
Owner string `gorm:"size:320;not null" json:"owner"`
Revision uint `gorm:"not null;default:1" json:"revision"`
}
func FirewallRuleOwner(sourceKind, sourceID string) string {
sourceKind = strings.TrimSpace(sourceKind)
sourceID = strings.TrimSpace(sourceID)
if sourceID == "" {
return sourceKind
}
return sourceKind + ":" + sourceID
}
func FirewallRuleFromDomain(rule filter.FirewallRule) (FirewallRule, error) {
normalized, err := filter.NormalizeRule(rule)
if err != nil {
return FirewallRule{}, err
}
switch normalized.NativeKind {
case "", filter.NativeKindRule, filter.NativeKindZonePort, filter.NativeKindRichRule, filter.NativeKindUFWRule:
default:
return FirewallRule{}, fmt.Errorf("%w: native rule %q cannot be stored as a provider-neutral policy", filter.ErrUnsupportedScope, normalized.NativeKind)
}
record := FirewallRule{
Family: string(normalized.Scope.Family),
Protocol: normalized.Protocol,
SourceAddress: normalized.SourceAddress,
SourcePort: normalized.SourcePort,
DestinationAddress: normalized.DestinationAddress,
DestinationPort: normalized.DestinationPort,
Interface: normalized.Interface,
ConnectionStates: strings.Join(normalized.ConnectionStates, ","),
Action: string(normalized.Action),
Description: normalized.Description,
}
if normalized.Scope.Provider == filter.ProviderFirewalld {
record.Priority = normalized.Priority
}
return record, nil
}
func (rule FirewallRule) PolicyKey() string {
payload, _ := json.Marshal(struct {
Family string `json:"family"`
Protocol string `json:"protocol"`
SourceAddress string `json:"sourceAddress,omitempty"`
SourcePort string `json:"sourcePort,omitempty"`
DestinationAddress string `json:"destinationAddress,omitempty"`
DestinationPort string `json:"destinationPort,omitempty"`
Interface string `json:"interface,omitempty"`
ConnectionStates string `json:"connectionStates,omitempty"`
Action string `json:"action"`
}{
Family: rule.Family, Protocol: rule.Protocol,
SourceAddress: rule.SourceAddress, SourcePort: rule.SourcePort,
DestinationAddress: rule.DestinationAddress, DestinationPort: rule.DestinationPort,
Interface: rule.Interface, ConnectionStates: rule.ConnectionStates, Action: rule.Action,
})
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
func (rule FirewallRule) RulesForProvider(provider filter.Provider) ([]filter.FirewallRule, error) {
if rule.CompatibilityError != "" {
return nil, fmt.Errorf("%w: %s", filter.ErrUnsupportedScope, rule.CompatibilityError)
}
connectionStates := make([]string, 0)
if rule.ConnectionStates != "" {
connectionStates = strings.Split(rule.ConnectionStates, ",")
}
base := filter.FirewallRule{
Protocol: rule.Protocol, SourceAddress: rule.SourceAddress, SourcePort: rule.SourcePort,
DestinationAddress: rule.DestinationAddress, DestinationPort: rule.DestinationPort,
Interface: rule.Interface, ConnectionStates: connectionStates,
Action: filter.Action(rule.Action), Description: rule.Description,
}
if provider != filter.ProviderUFW && strings.EqualFold(strings.TrimSpace(base.Protocol), "all") &&
strings.TrimSpace(base.SourcePort) == "" && strings.TrimSpace(base.DestinationPort) != "" {
base.Protocol = "tcp/udp"
}
if provider == filter.ProviderFirewalld {
base.Priority = rule.Priority
}
families := []filter.Family{filter.Family(rule.Family)}
if provider != filter.ProviderFirewalld && families[0] == filter.FamilyInet {
hasIPv4, hasIPv6 := ruleAddressFamilies(base)
switch {
case hasIPv4 && hasIPv6:
return nil, fmt.Errorf("%w: inet policy contains both IPv4 and IPv6 addresses", filter.ErrUnsupportedScope)
case hasIPv6 || strings.EqualFold(base.Protocol, "icmpv6"):
families = []filter.Family{filter.FamilyIPv6}
case hasIPv4:
families = []filter.Family{filter.FamilyIPv4}
default:
families = []filter.Family{filter.FamilyIPv4, filter.FamilyIPv6}
}
}
result := make([]filter.FirewallRule, 0, len(families))
for _, family := range families {
compiled := base
compiled.Scope = filter.Scope{Provider: provider, Family: family, Direction: filter.DirectionInput}
switch provider {
case filter.ProviderIptables, filter.ProviderNftables:
compiled.Scope.Table, compiled.Scope.Chain = "filter", filter.IptablesInputChain
case filter.ProviderFirewalld:
compiled.Scope.Zone = filter.FirewalldInputZone
case filter.ProviderUFW:
compiled.Scope.Chain = filter.UFWInputChain
default:
return nil, fmt.Errorf("%w: unsupported firewall provider %q", filter.ErrProviderUnavailable, provider)
}
expanded, err := filter.ExpandAtomicRules(compiled)
if err != nil {
return nil, err
}
result = append(result, expanded...)
}
return result, nil
}
func SortFirewallRules(rules []FirewallRule, provider filter.Provider) {
sort.SliceStable(rules, func(i, j int) bool {
left, right := rules[i], rules[j]
if provider == filter.ProviderFirewalld {
switch {
case left.Priority == nil && right.Priority != nil:
return false
case left.Priority != nil && right.Priority == nil:
return true
case left.Priority != nil && right.Priority != nil && *left.Priority != *right.Priority:
return *left.Priority < *right.Priority
}
} else {
switch {
case left.Sequence == nil && right.Sequence != nil:
return false
case left.Sequence != nil && right.Sequence == nil:
return true
case left.Sequence != nil && right.Sequence != nil && *left.Sequence != *right.Sequence:
return *left.Sequence < *right.Sequence
}
}
return left.UUID < right.UUID
})
}
func ruleAddressFamilies(rule filter.FirewallRule) (bool, bool) {
hasIPv4, hasIPv6 := false, false
for _, address := range []string{rule.SourceAddress, rule.DestinationAddress} {
address = strings.TrimSpace(address)
if address == "" {
continue
}
if strings.Contains(address, ":") {
hasIPv6 = true
} else {
hasIPv4 = true
}
}
return hasIPv4, hasIPv6
}
+83 -7
View File
@@ -1,5 +1,7 @@
package model package model
import "time"
type MonitorBase struct { type MonitorBase struct {
BaseModel BaseModel
Cpu float64 `json:"cpu"` Cpu float64 `json:"cpu"`
@@ -33,14 +35,88 @@ type MonitorNetwork struct {
} }
type MonitorGPU struct { type MonitorGPU struct {
MemoryUtil *float64 `json:"memoryUtil"`
MemoryActivity *float64 `json:"memoryActivity"`
EncoderUtil *float64 `json:"encoderUtil"`
DecoderUtil *float64 `json:"decoderUtil"`
JPEGUtil *float64 `json:"jpegUtil"`
OFAUtil *float64 `json:"ofaUtil"`
MediaUtil *float64 `json:"mediaUtil"`
ComputeUtil *float64 `json:"computeUtil"`
CopyUtil *float64 `json:"copyUtil"`
HotspotTemperature *float64 `json:"hotspotTemperature"`
FanRPM *float64 `json:"fanRPM"`
AICPUUtil *float64 `json:"aiCPUUtil"`
CtrlCPUUtil *float64 `json:"ctrlCPUUtil"`
DDRUsed *float64 `json:"ddrUsed"`
DDRTotal *float64 `json:"ddrTotal"`
HBMUsed *float64 `json:"hbmUsed"`
HBMTotal *float64 `json:"hbmTotal"`
DDRBandwidth *float64 `json:"ddrBandwidth"`
HBMBandwidth *float64 `json:"hbmBandwidth"`
MemoryBandwidth *float64 `json:"memoryBandwidth"`
MediaFrequency *float64 `json:"mediaFrequency"`
HugepagesUsed *float64 `json:"hugepagesUsed"`
HugepagesTotal *float64 `json:"hugepagesTotal"`
MemoryTemperature *float64 `json:"memoryTemperature"`
DeviceID string `json:"deviceID"`
DeviceType string `json:"deviceType"`
ProcessStatus string `json:"processStatus"`
Frequency *float64 `json:"frequency"`
MemoryFrequency *float64 `json:"memoryFrequency"`
IntervalSeconds int `json:"intervalSeconds"`
BaseModel BaseModel
ProductName string `json:"productName"` ProductName string `json:"productName"`
GPUUtil float64 `json:"gpuUtil"` GPUUtil *float64 `json:"gpuUtil"`
Temperature float64 `json:"temperature"` Temperature *float64 `json:"temperature"`
PowerDraw float64 `json:"powerDraw"` PowerDraw *float64 `json:"powerDraw"`
MaxPowerLimit float64 `json:"maxPowerLimit"` MaxPowerLimit *float64 `json:"maxPowerLimit"`
MemUsed float64 `json:"memUsed"` MemUsed *float64 `json:"memUsed"`
MemTotal float64 `json:"memTotal"` MemTotal *float64 `json:"memTotal"`
FanSpeed int `json:"fanSpeed"` FanSpeed *float64 `json:"fanSpeed"`
Processes string `json:"processes"` Processes string `json:"processes"`
} }
type MonitorVLLM struct {
ID uint `json:"-" gorm:"primarykey;autoIncrement"`
CreatedAt time.Time `json:"createdAt"`
AppInstallID uint `json:"appInstallID"`
Status string `json:"status"`
RawMetrics string `json:"-"`
HistogramDeltas string `json:"-"`
IntervalSeconds float64 `json:"intervalSeconds" gorm:"-"`
SampleIntervalSeconds int `json:"-"`
RequestDeltas string `json:"-"`
CompletedRequests *float64 `json:"completedRequests" gorm:"-"`
QueueTime *float64 `json:"queueTime"`
AverageGenerationTokens *float64 `json:"averageGenerationTokens"`
PrefixCacheHitRate *float64 `json:"prefixCacheHitRate"`
FinishReasons map[string]float64 `json:"finishReasons" gorm:"-"`
PromptDistribution map[string]float64 `json:"promptDistribution" gorm:"-"`
GenerationDistribution map[string]float64 `json:"generationDistribution" gorm:"-"`
Running *float64 `json:"running"`
Waiting *float64 `json:"waiting"`
CacheUsage *float64 `json:"cacheUsage"`
PromptThroughput *float64 `json:"promptThroughput"`
GenerationThroughput *float64 `json:"generationThroughput"`
RequestThroughput *float64 `json:"requestThroughput"`
TimeToFirstToken *float64 `json:"timeToFirstToken"`
TimePerOutputToken *float64 `json:"timePerOutputToken"`
RequestLatency *float64 `json:"requestLatency"`
PrefillTime *float64 `json:"prefillTime"`
DecodeTime *float64 `json:"decodeTime"`
TimeToFirstTokenP50 *float64 `json:"timeToFirstTokenP50"`
TimeToFirstTokenP90 *float64 `json:"timeToFirstTokenP90"`
TimeToFirstTokenP95 *float64 `json:"timeToFirstTokenP95"`
TimeToFirstTokenP99 *float64 `json:"timeToFirstTokenP99"`
TimePerOutputTokenP50 *float64 `json:"timePerOutputTokenP50"`
TimePerOutputTokenP90 *float64 `json:"timePerOutputTokenP90"`
TimePerOutputTokenP95 *float64 `json:"timePerOutputTokenP95"`
TimePerOutputTokenP99 *float64 `json:"timePerOutputTokenP99"`
RequestLatencyP50 *float64 `json:"requestLatencyP50"`
RequestLatencyP90 *float64 `json:"requestLatencyP90"`
RequestLatencyP95 *float64 `json:"requestLatencyP95"`
RequestLatencyP99 *float64 `json:"requestLatencyP99"`
}
+2 -3
View File
@@ -66,7 +66,7 @@ var catalog = map[string]Meta{
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"}, {APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey), anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
}, },
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}}, Models: []Model{{ID: "deepseek-flash", Name: "deepseek-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
}, },
"bailian-coding-plan": { "bailian-coding-plan": {
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY", Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
@@ -82,7 +82,6 @@ var catalog = map[string]Meta{
{ID: "kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi-k2.5", Name: "Kimi K2.5"},
{ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"}, {ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"},
{ID: "glm-5", Name: "GLM-5"}, {ID: "glm-5", Name: "GLM-5"},
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
{ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"}, {ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"},
{ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"}, {ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"},
}, },
@@ -114,7 +113,7 @@ var catalog = map[string]Meta{
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"}, {APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"}, {APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
}, },
Models: []Model{{ID: "MiniMax-M3", Name: "MiniMax M3"}, {ID: "MiniMax-M2.7", Name: "MiniMax M2.7"}, {ID: "MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"}}, Models: []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}},
}, },
"xiaomi": { "xiaomi": {
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY", Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
+100
View File
@@ -0,0 +1,100 @@
package provider
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestMiniMaxCatalog(t *testing.T) {
meta, ok := Get("minimax")
if !ok || !reflect.DeepEqual(meta.Models, []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}}) {
t.Fatalf("unexpected MiniMax models: %+v", meta.Models)
}
if meta.DefaultAPIType != "anthropic-messages" {
t.Fatalf("changed default protocol: %s", meta.DefaultAPIType)
}
for _, provider := range All() {
for _, model := range provider.Models {
if strings.Contains(strings.ToLower(model.ID), "minimax") && model.ID != "MiniMax-M3.1-Flash" && model.ID != "MiniMax-M3" {
t.Errorf("legacy suggestion in %s: %s", provider.Key, model.ID)
}
}
}
}
func TestMiniMaxVerificationRequests(t *testing.T) {
for _, region := range []string{"https://api.minimaxi.com", "https://api.minimax.io"} {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
for _, auth := range []string{AuthModeXAPIKey, AuthModeBearer} {
for _, model := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "custom-model"} {
t.Run(region+"/"+protocol+"/"+auth+"/"+model, func(t *testing.T) {
base, endpoint := region+"/v1", "/chat/completions"
if protocol == "anthropic-messages" {
base, endpoint = region+"/anthropic", "/v1/messages"
}
built := BuildVerifyRequest("minimax", protocol, auth, base, "test-key", model)
if built.URL != base+endpoint {
t.Fatalf("URL = %s", built.URL)
}
var captured map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != strings.TrimPrefix(base, region)+endpoint {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if protocol == "anthropic-messages" && auth == AuthModeXAPIKey {
if r.Header.Get("x-api-key") != "test-key" || r.Header.Get("Authorization") != "" {
t.Error("incorrect API key authentication")
}
} else if r.Header.Get("Authorization") != "Bearer test-key" {
t.Error("incorrect bearer authentication")
}
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
if err := VerifyAccount("minimax", protocol, auth, server.URL+strings.TrimPrefix(base, region), "test-key", model); err != nil {
t.Fatal(err)
}
if captured["model"] != model || captured["stream"] != false || captured["max_tokens"] != float64(defaultVerifyMaxTokens) {
t.Fatalf("unexpected serialized body: %#v", captured)
}
if model == "MiniMax-M3.1-Flash" {
if protocol == "anthropic-messages" {
if !reflect.DeepEqual(captured["thinking"], map[string]interface{}{"type": "adaptive"}) || !reflect.DeepEqual(captured["output_config"], map[string]interface{}{"effort": "max"}) || captured["reasoning_effort"] != nil {
t.Fatalf("invalid Anthropic Flash reasoning: %#v", captured)
}
} else if captured["reasoning_effort"] != "max" || captured["thinking"] != nil || captured["output_config"] != nil {
t.Fatalf("invalid OpenAI Flash reasoning: %#v", captured)
}
} else if captured["reasoning_effort"] != nil || captured["thinking"] != nil || captured["output_config"] != nil {
t.Fatalf("changed legacy/custom reasoning: %#v", captured)
}
})
}
}
}
}
}
func TestMiniMaxExplicitGatewayAndCustomModels(t *testing.T) {
for _, provider := range []string{"bailian-coding-plan", "custom", "openrouter"} {
for _, protocol := range []string{"openai-completions", "anthropic-messages"} {
for _, model := range []string{"MiniMax-M2.5", "MiniMax-M3.1-Flash", "my-custom-model"} {
request := BuildVerifyRequest(provider, protocol, AuthModeBearer, "https://example.test/v1", "test-key", model)
var body map[string]interface{}
if err := json.Unmarshal(request.Body, &body); err != nil {
t.Fatal(err)
}
if body["model"] != model || body["reasoning_effort"] != nil || body["output_config"] != nil || body["thinking"] != nil {
t.Fatalf("changed explicit %s request: %#v", provider, body)
}
}
}
}
}
+13 -4
View File
@@ -118,10 +118,15 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
headers["x-api-key"] = apiKey headers["x-api-key"] = apiKey
} }
headers["anthropic-version"] = "2023-06-01" headers["anthropic-version"] = "2023-06-01"
request.Body = mustJSON(map[string]interface{}{ body := map[string]interface{}{
"model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false, "model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false,
"messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}}, "messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}},
}) }
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
body["thinking"] = map[string]string{"type": "adaptive"}
body["output_config"] = map[string]string{"effort": "max"}
}
request.Body = mustJSON(body)
case "openai-responses": case "openai-responses":
request.URL = baseURL + "/responses" request.URL = baseURL + "/responses"
headers["Authorization"] = "Bearer " + apiKey headers["Authorization"] = "Bearer " + apiKey
@@ -131,9 +136,13 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" { if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" {
headers["Authorization"] = "Bearer " + apiKey headers["Authorization"] = "Bearer " + apiKey
} }
request.Body = mustJSON(map[string]interface{}{ body := map[string]interface{}{
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false, "model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false,
}) }
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
body["reasoning_effort"] = "max"
}
request.Body = mustJSON(body)
} }
return request return request
} }
+21
View File
@@ -4,6 +4,7 @@ import (
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -18,6 +19,7 @@ type IAgentAccountRepo interface {
DeleteByID(id uint) error DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error) List(opts ...DBOption) ([]model.AgentAccount, error)
CountTextByProviders(providers []string) (map[string]int64, error) CountTextByProviders(providers []string) (map[string]int64, error)
SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error
} }
func NewIAgentAccountRepo() IAgentAccountRepo { func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -97,6 +99,25 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
return counts, nil return counts, nil
} }
func (a AgentAccountRepo) SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", account.ID).Delete(&model.AgentAccountModel{}).Error; err != nil {
return err
}
if len(models) == 0 {
return nil
}
for i := range models {
models[i].AccountID = account.ID
models[i].SortOrder = i + 1
}
return tx.Create(&models).Error
})
}
func normalizeProviders(providers []string) []string { func normalizeProviders(providers []string) []string {
seen := make(map[string]struct{}, len(providers)) seen := make(map[string]struct{}, len(providers))
result := make([]string, 0, len(providers)) result := make([]string, 0, len(providers))
+7
View File
@@ -1,6 +1,8 @@
package repo package repo
import ( import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
) )
@@ -14,6 +16,7 @@ type IAiRepo interface {
Create(cronjob *model.OllamaModel) error Create(cronjob *model.OllamaModel) error
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteAll(ctx context.Context) error
} }
func NewIAiRepo() IAiRepo { func NewIAiRepo() IAiRepo {
@@ -67,3 +70,7 @@ func (u *AiRepo) Delete(opts ...DBOption) error {
} }
return db.Delete(&model.OllamaModel{}).Error return db.Delete(&model.OllamaModel{}).Error
} }
func (u *AiRepo) DeleteAll(ctx context.Context) error {
return getTx(ctx).Where("1 = 1").Delete(&model.OllamaModel{}).Error
}
+69 -3
View File
@@ -3,6 +3,7 @@ package repo
import ( import (
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
) )
type LauncherRepo struct{} type LauncherRepo struct{}
@@ -17,6 +18,11 @@ type ILauncherRepo interface {
GetQuickJump(opts ...DBOption) (model.QuickJump, error) GetQuickJump(opts ...DBOption) (model.QuickJump, error)
ListQuickJump(withAll bool) []model.QuickJump ListQuickJump(withAll bool) []model.QuickJump
UpdateQuicks(quicks []model.QuickJump) error UpdateQuicks(quicks []model.QuickJump) error
ListQuickDirectories() ([]model.QuickDirectory, error)
CountQuickDirectories() (int64, error)
CreateQuickDirectory(item *model.QuickDirectory) error
UpdateQuickDirectory(item *model.QuickDirectory) error
DeleteQuickDirectory(id uint, legacy bool) error
} }
func NewILauncherRepo() ILauncherRepo { func NewILauncherRepo() ILauncherRepo {
@@ -95,13 +101,73 @@ func (u *LauncherRepo) UpdateQuicks(quicks []model.QuickJump) error {
for _, item := range quicks { for _, item := range quicks {
if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{ if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
"is_show": item.IsShow, "is_show": item.IsShow,
"detail": item.Detail,
"alias": item.Alias,
}).Error; err != nil { }).Error; err != nil {
tx.Rollback() tx.Rollback()
return err return err
} }
} }
tx.Commit() return tx.Commit().Error
}
// Legacy entries remain in the overview configuration until explicitly deleted.
func legacyQuickDirectories(db *gorm.DB) *gorm.DB {
return db.Model(&model.QuickJump{}).Where("name = ? AND is_show = ? AND detail <> ?", "File", true, "")
}
func (u *LauncherRepo) ListQuickDirectories() ([]model.QuickDirectory, error) {
items := make([]model.QuickDirectory, 0)
if err := global.DB.Order("id ASC").Find(&items).Error; err != nil {
return nil, err
}
var quicks []model.QuickJump
if err := legacyQuickDirectories(global.DB).Order("id ASC").Find(&quicks).Error; err != nil {
return nil, err
}
for _, quick := range quicks {
items = append(items, model.QuickDirectory{BaseModel: quick.BaseModel, Path: quick.Detail, Alias: quick.Alias, Legacy: true})
}
return items, nil
}
func (u *LauncherRepo) CountQuickDirectories() (int64, error) {
var count, legacyCount int64
if err := global.DB.Model(&model.QuickDirectory{}).Count(&count).Error; err != nil {
return 0, err
}
if err := legacyQuickDirectories(global.DB).Count(&legacyCount).Error; err != nil {
return 0, err
}
return count + legacyCount, nil
}
func (u *LauncherRepo) CreateQuickDirectory(item *model.QuickDirectory) error {
return global.DB.Create(item).Error
}
func (u *LauncherRepo) UpdateQuickDirectory(item *model.QuickDirectory) error {
var result *gorm.DB
if item.Legacy {
result = legacyQuickDirectories(global.DB).Where("id = ?", item.ID).Updates(map[string]interface{}{"detail": item.Path, "alias": item.Alias})
} else {
result = global.DB.Model(&model.QuickDirectory{}).Where("id = ?", item.ID).Updates(map[string]interface{}{"path": item.Path, "alias": item.Alias})
}
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (u *LauncherRepo) DeleteQuickDirectory(id uint, legacy bool) error {
var result *gorm.DB
if legacy {
result = legacyQuickDirectories(global.DB).Where("id = ?", id).Updates(map[string]interface{}{"detail": nil, "alias": nil})
} else {
result = global.DB.Delete(&model.QuickDirectory{}, id)
}
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil return nil
} }
-77
View File
@@ -1,77 +0,0 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type IDockerPortGuardRepo interface {
ListManaged(context.Context) ([]model.DockerPortGuardPolicy, error)
ListRuntimeReadOnly(context.Context) ([]model.DockerPortGuardPolicy, error)
DeleteBatch(context.Context, []string) error
UpsertBatch(context.Context, []model.DockerPortGuardPolicy) error
ReplaceRuntimeReadOnly(context.Context, []model.DockerPortGuardPolicy) error
}
type DockerPortGuardRepo struct{}
func NewIDockerPortGuardRepo() IDockerPortGuardRepo { return &DockerPortGuardRepo{} }
func (r *DockerPortGuardRepo) ListManaged(ctx context.Context) ([]model.DockerPortGuardPolicy, error) {
var policies []model.DockerPortGuardPolicy
err := global.DB.WithContext(ctx).
Where("read_only = ?", false).
Order("family, host_ip, host_port, protocol").
Find(&policies).Error
return policies, err
}
func (r *DockerPortGuardRepo) ListRuntimeReadOnly(ctx context.Context) ([]model.DockerPortGuardPolicy, error) {
var policies []model.DockerPortGuardPolicy
err := global.DB.WithContext(ctx).
Where("read_only = ?", true).
Order("family, sequence, host_ip, host_port, protocol").
Find(&policies).Error
return policies, err
}
func (r *DockerPortGuardRepo) DeleteBatch(ctx context.Context, uuids []string) error {
return global.DB.WithContext(ctx).
Where("read_only = ? AND uuid IN ?", false, uuids).
Delete(&model.DockerPortGuardPolicy{}).Error
}
func (r *DockerPortGuardRepo) UpsertBatch(ctx context.Context, policies []model.DockerPortGuardPolicy) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i := range policies {
policies[i].ReadOnly = false
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "read_only"}, {Name: "family"}, {Name: "host_ip"}, {Name: "host_port"}, {Name: "protocol"}},
DoUpdates: clause.AssignmentColumns([]string{"mode", "sources", "description", "updated_at"}),
}).Create(&policies[i]).Error; err != nil {
return err
}
}
return nil
})
}
func (r *DockerPortGuardRepo) ReplaceRuntimeReadOnly(ctx context.Context, policies []model.DockerPortGuardPolicy) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("read_only = ?", true).
Delete(&model.DockerPortGuardPolicy{}).Error; err != nil {
return err
}
if len(policies) == 0 {
return nil
}
for i := range policies {
policies[i].ReadOnly = true
}
return tx.Create(&policies).Error
})
}
-144
View File
@@ -1,144 +0,0 @@
package repo
import (
"context"
"errors"
"fmt"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
ErrFirewallRuleRevisionConflict = errors.New("firewall rule revision conflict")
ErrFirewallPersistenceInvalid = errors.New("invalid firewall persistence record")
)
type IFirewallRuleRepo interface {
Create(context.Context, *model.FirewallRule) error
GetByUUID(context.Context, string) (model.FirewallRule, error)
List(context.Context, ...DBOption) ([]model.FirewallRule, error)
UpdateWithRevision(context.Context, string, uint, map[string]interface{}) error
DeleteWithRevision(context.Context, string, uint) error
}
type FirewallRuleRepo struct {
db *gorm.DB
}
func NewIFirewallRuleRepo() IFirewallRuleRepo {
return &FirewallRuleRepo{}
}
func NewFirewallRuleRepo(db *gorm.DB) *FirewallRuleRepo {
return &FirewallRuleRepo{db: db}
}
func (r *FirewallRuleRepo) Create(ctx context.Context, rule *model.FirewallRule) error {
if err := prepareFirewallRule(rule); err != nil {
return err
}
return r.dbFor(ctx).Create(rule).Error
}
func (r *FirewallRuleRepo) GetByUUID(ctx context.Context, ruleUUID string) (model.FirewallRule, error) {
var rule model.FirewallRule
err := r.dbFor(ctx).Where("uuid = ?", ruleUUID).First(&rule).Error
return rule, err
}
func (r *FirewallRuleRepo) List(ctx context.Context, opts ...DBOption) ([]model.FirewallRule, error) {
var rules []model.FirewallRule
db := r.dbFor(ctx).Model(&model.FirewallRule{})
for _, opt := range opts {
db = opt(db)
}
return rules, db.Find(&rules).Error
}
func (r *FirewallRuleRepo) UpdateWithRevision(ctx context.Context, ruleUUID string, expectedRevision uint, updates map[string]interface{}) error {
updates = sanitizeRuleUpdates(updates)
updates["revision"] = gorm.Expr("revision + 1")
result := r.dbFor(ctx).Model(&model.FirewallRule{}).
Where("uuid = ? AND revision = ?", ruleUUID, expectedRevision).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrFirewallRuleRevisionConflict
}
return nil
}
func (r *FirewallRuleRepo) DeleteWithRevision(ctx context.Context, ruleUUID string, expectedRevision uint) error {
result := r.dbFor(ctx).
Where("uuid = ? AND revision = ?", ruleUUID, expectedRevision).
Delete(&model.FirewallRule{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrFirewallRuleRevisionConflict
}
return nil
}
func (r *FirewallRuleRepo) dbFor(ctx context.Context) *gorm.DB {
return firewallDB(ctx, r.db)
}
func firewallDB(ctx context.Context, fallback *gorm.DB) *gorm.DB {
if ctx == nil {
ctx = context.Background()
}
if tx, ok := ctx.Value(constant.DB).(*gorm.DB); ok && tx != nil {
return tx.WithContext(ctx)
}
if fallback == nil {
fallback = global.DB
}
return fallback.WithContext(ctx)
}
func prepareFirewallRule(rule *model.FirewallRule) error {
if rule == nil {
return fmt.Errorf("%w: rule is nil", ErrFirewallPersistenceInvalid)
}
if rule.Family == "" || rule.Protocol == "" || rule.Action == "" {
return fmt.Errorf("%w: atomic rule identity fields are required", ErrFirewallPersistenceInvalid)
}
if rule.UUID == "" {
rule.UUID = uuid.NewString()
}
if rule.Revision == 0 {
rule.Revision = 1
}
if rule.Origin == "" {
rule.Origin = constant.FirewallRuleOriginCreated
}
if rule.Owner == "" {
rule.Owner = constant.FirewallRuleSourceUser
}
return nil
}
func sanitizeRuleUpdates(updates map[string]interface{}) map[string]interface{} {
result := cloneUpdates(updates)
delete(result, "id")
delete(result, "uuid")
delete(result, "revision")
delete(result, "created_at")
return result
}
func cloneUpdates(updates map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{}, len(updates)+1)
for key, value := range updates {
result[key] = value
}
return result
}
-36
View File
@@ -1,36 +0,0 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type IForwardingRuleRepo interface {
List(context.Context) ([]model.ForwardingRule, error)
ReplaceAll(context.Context, []model.ForwardingRule) error
}
type ForwardingRuleRepo struct{}
func NewIForwardingRuleRepo() IForwardingRuleRepo { return &ForwardingRuleRepo{} }
func (r *ForwardingRuleRepo) List(ctx context.Context) ([]model.ForwardingRule, error) {
var rules []model.ForwardingRule
err := global.DB.WithContext(ctx).Order("id ASC").Find(&rules).Error
return rules, err
}
func (r *ForwardingRuleRepo) ReplaceAll(ctx context.Context, rules []model.ForwardingRule) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ForwardingRule{}).Error; err != nil {
return err
}
if len(rules) == 0 {
return nil
}
return tx.Create(&rules).Error
})
}
+103 -13
View File
@@ -1,6 +1,8 @@
package repo package repo
import ( import (
"fmt"
"strings"
"time" "time"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
@@ -10,11 +12,25 @@ import (
type MonitorRepo struct{} type MonitorRepo struct{}
type GPUHistoryPoint struct {
model.MonitorGPU
Bucket int64
PowerPercent *float64
MemoryPercent *float64
ProcessCount *float64
}
type IMonitorRepo interface { type IMonitorRepo interface {
CleanHost() error
CleanGPU() error
GetBase(opts ...DBOption) ([]model.MonitorBase, error) GetBase(opts ...DBOption) ([]model.MonitorBase, error)
GetGPU(opts ...DBOption) ([]model.MonitorGPU, error) CountGPU(opts ...DBOption) (int64, error)
GetGPUHistory(start time.Time, bucketSeconds int64, aggregation string, opts ...DBOption) ([]GPUHistoryPoint, error)
GetGPUDevices() ([]model.MonitorGPU, error)
GetIO(opts ...DBOption) ([]model.MonitorIO, error) GetIO(opts ...DBOption) ([]model.MonitorIO, error)
GetNetwork(opts ...DBOption) ([]model.MonitorNetwork, error) GetNetwork(opts ...DBOption) ([]model.MonitorNetwork, error)
GetIONames() ([]string, error)
GetNetworkNames() ([]string, error)
CreateMonitorBase(model model.MonitorBase) error CreateMonitorBase(model model.MonitorBase) error
BatchCreateMonitorGPU(list []model.MonitorGPU) error BatchCreateMonitorGPU(list []model.MonitorGPU) error
@@ -25,13 +41,26 @@ type IMonitorRepo interface {
DelMonitorIO(timeForDelete time.Time) error DelMonitorIO(timeForDelete time.Time) error
DelMonitorNet(timeForDelete time.Time) error DelMonitorNet(timeForDelete time.Time) error
WithByProductName(name string) DBOption WithByGPUDevice(deviceID, name string, legacy bool) DBOption
} }
func NewIMonitorRepo() IMonitorRepo { func NewIMonitorRepo() IMonitorRepo {
return &MonitorRepo{} return &MonitorRepo{}
} }
func (s *MonitorRepo) CleanHost() error {
for _, item := range []interface{}{&model.MonitorBase{}, &model.MonitorIO{}, &model.MonitorNetwork{}} {
if err := global.MonitorDB.Where("1 = 1").Delete(item).Error; err != nil {
return err
}
}
return nil
}
func (s *MonitorRepo) CleanGPU() error {
return global.GPUMonitorDB.Where("1 = 1").Delete(&model.MonitorGPU{}).Error
}
func (u *MonitorRepo) GetBase(opts ...DBOption) ([]model.MonitorBase, error) { func (u *MonitorRepo) GetBase(opts ...DBOption) ([]model.MonitorBase, error) {
var data []model.MonitorBase var data []model.MonitorBase
db := global.MonitorDB db := global.MonitorDB
@@ -59,14 +88,16 @@ func (u *MonitorRepo) GetNetwork(opts ...DBOption) ([]model.MonitorNetwork, erro
err := db.Find(&data).Error err := db.Find(&data).Error
return data, err return data, err
} }
func (u *MonitorRepo) GetGPU(opts ...DBOption) ([]model.MonitorGPU, error) { func (u *MonitorRepo) GetIONames() ([]string, error) {
var data []model.MonitorGPU var names []string
db := global.GPUMonitorDB err := global.MonitorDB.Model(&model.MonitorIO{}).Distinct().Pluck("name", &names).Error
for _, opt := range opts { return names, err
db = opt(db) }
}
err := db.Find(&data).Error func (u *MonitorRepo) GetNetworkNames() ([]string, error) {
return data, err var names []string
err := global.MonitorDB.Model(&model.MonitorNetwork{}).Distinct().Pluck("name", &names).Error
return names, err
} }
func (u *MonitorRepo) CreateMonitorBase(model model.MonitorBase) error { func (u *MonitorRepo) CreateMonitorBase(model model.MonitorBase) error {
@@ -97,8 +128,67 @@ func (s *MonitorRepo) DelMonitorGPU(timeForDelete time.Time) error {
return global.GPUMonitorDB.Where("created_at < ?", timeForDelete).Delete(&model.MonitorGPU{}).Error return global.GPUMonitorDB.Where("created_at < ?", timeForDelete).Delete(&model.MonitorGPU{}).Error
} }
func (s *MonitorRepo) WithByProductName(name string) DBOption { func (u *MonitorRepo) GetGPUDevices() ([]model.MonitorGPU, error) {
return func(g *gorm.DB) *gorm.DB { var data []model.MonitorGPU
return g.Where("product_name = ?", name) err := global.GPUMonitorDB.Model(&model.MonitorGPU{}).Select("device_id, product_name, device_type").Group("device_id, product_name, device_type").Order("product_name, device_id").Find(&data).Error
return data, err
}
func (u *MonitorRepo) WithByGPUDevice(deviceID, name string, legacy bool) DBOption {
return func(db *gorm.DB) *gorm.DB {
if deviceID != "" {
return db.Where("device_id = ?", deviceID)
}
db = db.Where("product_name = ?", name)
if legacy {
db = db.Where("device_id IS NULL OR device_id = ''")
}
return db
} }
} }
func (u *MonitorRepo) CountGPU(opts ...DBOption) (int64, error) {
db := global.GPUMonitorDB.Model(&model.MonitorGPU{})
for _, opt := range opts {
db = opt(db)
}
var count int64
err := db.Count(&count).Error
return count, err
}
func (u *MonitorRepo) GetGPUHistory(start time.Time, bucketSeconds int64, aggregation string, opts ...DBOption) ([]GPUHistoryPoint, error) {
db := global.GPUMonitorDB.Model(&model.MonitorGPU{})
for _, opt := range opts {
db = opt(db)
}
expressions := []string{
"CASE WHEN max_power_limit > 0 THEN 100.0 * power_draw / max_power_limit END",
"CASE WHEN mem_total > 0 AND mem_used IS NOT NULL THEN 100.0 * mem_used / mem_total ELSE memory_util END",
"CASE WHEN (process_status = 'ok' OR process_status IS NULL OR process_status = '') AND json_valid(processes) THEN CASE WHEN json_type(processes) = 'array' THEN json_array_length(processes) END END",
}
aliases := []string{"power_percent", "memory_percent", "process_count"}
columns := []string{"*"}
if bucketSeconds > 0 {
operation := "AVG"
if aggregation == "max" {
operation = "MAX"
}
columns = []string{fmt.Sprintf("(CAST(strftime('%%s', created_at) AS INTEGER) - %d) / %d AS bucket", start.Unix(), bucketSeconds)}
for _, column := range []string{"memory_activity", "encoder_util", "decoder_util", "jpeg_util", "ofa_util", "media_util", "compute_util", "copy_util", "hotspot_temperature", "fan_rpm", "ai_cpu_util", "ctrl_cpu_util", "ddr_used", "ddr_total", "hbm_used", "hbm_total", "ddr_bandwidth", "hbm_bandwidth", "memory_bandwidth", "media_frequency", "hugepages_used", "hugepages_total", "gpu_util", "temperature", "memory_temperature", "power_draw", "max_power_limit", "mem_used", "mem_total", "frequency", "memory_frequency", "fan_speed"} {
columns = append(columns, operation+"("+column+") AS "+column)
}
for i := range expressions {
expressions[i] = operation + "(" + expressions[i] + ")"
}
db = db.Group("bucket").Order("bucket ASC")
} else {
db = db.Order("created_at ASC, id ASC")
}
for i, expression := range expressions {
columns = append(columns, expression+" AS "+aliases[i])
}
var data []GPUHistoryPoint
err := db.Select(strings.Join(columns, ", ")).Scan(&data).Error
return data, err
}
+132
View File
@@ -0,0 +1,132 @@
package repo
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type VLLMMonitorRepo struct{}
type VLLMHistoryPoint struct {
model.MonitorVLLM
Bucket int64
CompletedRequests *float64
}
func (r *VLLMMonitorRepo) Create(point *model.MonitorVLLM) error {
return global.VLLMMonitorDB.Create(point).Error
}
func (r *VLLMMonitorRepo) Latest(id uint) (model.MonitorVLLM, error) {
var point model.MonitorVLLM
db := global.VLLMMonitorDB.Where("app_install_id = ?", id)
err := db.Order("created_at DESC, id DESC").First(&point).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return point, nil
}
return point, err
}
func (r *VLLMMonitorRepo) CleanTarget(id uint) error {
return global.VLLMMonitorDB.Where("app_install_id = ?", id).Delete(&model.MonitorVLLM{}).Error
}
func (r *VLLMMonitorRepo) DeleteBefore(before time.Time) error {
return global.VLLMMonitorDB.Where("created_at < ?", before).Delete(&model.MonitorVLLM{}).Error
}
func (r *VLLMMonitorRepo) Count(ctx context.Context, id uint, start, end time.Time) (int64, error) {
var count int64
db := global.VLLMMonitorDB.WithContext(ctx).Model(&model.MonitorVLLM{}).Where("app_install_id = ? AND created_at >= ? AND created_at <= ?", id, start, end)
err := db.Count(&count).Error
return count, err
}
func (r *VLLMMonitorRepo) History(ctx context.Context, id uint, start, end time.Time, seconds int64, aggregation, group string) ([]VLLMHistoryPoint, error) {
db := global.VLLMMonitorDB.WithContext(ctx).Model(&model.MonitorVLLM{}).Where("app_install_id = ? AND created_at >= ? AND created_at <= ?", id, start, end)
var metrics []string
if group == "" || group == "overview" {
metrics = append(metrics, "running", "waiting", "cache_usage", "prompt_throughput", "generation_throughput", "request_throughput")
if seconds <= 0 || aggregation == "max" {
metrics = append(metrics, "prefix_cache_hit_rate")
}
}
if seconds <= 0 || aggregation == "max" {
if group == "" || group == "latency" {
metrics = append(metrics, "queue_time", "time_to_first_token", "time_per_output_token", "request_latency", "prefill_time", "decode_time", "time_to_first_token_p50", "time_to_first_token_p90", "time_to_first_token_p95", "time_to_first_token_p99", "time_per_output_token_p50", "time_per_output_token_p90", "time_per_output_token_p95", "time_per_output_token_p99", "request_latency_p50", "request_latency_p90", "request_latency_p95", "request_latency_p99")
}
if group == "" || group == "requests" {
metrics = append(metrics, "average_generation_tokens")
}
}
var columns []string
if seconds > 0 {
operation := "AVG"
if aggregation == "max" {
operation = "MAX"
}
columns = []string{fmt.Sprintf("(CAST(strftime('%%s', created_at) AS INTEGER) - %d) / %d AS bucket", start.Unix(), seconds)}
for _, column := range metrics {
columns = append(columns, operation+"("+column+") AS "+column)
}
db = db.Group("bucket").Order("bucket ASC")
} else {
columns = append([]string{"id", "created_at", "app_install_id", "status", "sample_interval_seconds"}, metrics...)
if group == "" || group == "requests" {
columns = append(columns, "histogram_deltas", "request_deltas")
}
db = db.Order("created_at ASC, id ASC")
}
if group == "" || group == "overview" {
count := "CASE WHEN json_valid(request_deltas) THEN CASE WHEN json_extract(request_deltas, '$.seconds') > 0 THEN ROUND(request_throughput * json_extract(request_deltas, '$.seconds')) END END"
if seconds > 0 {
count = "SUM(" + count + ")"
}
columns = append(columns, count+" AS completed_requests")
}
var points []VLLMHistoryPoint
err := db.Select(strings.Join(columns, ", ")).Scan(&points).Error
return points, err
}
func (r *VLLMMonitorRepo) WalkDeltas(ctx context.Context, id uint, start, end time.Time, seconds int64, aggregation, group string, visit func(int64, string, string) error) error {
histograms, requests := "''", "''"
if group == "" || group == "requests" || (group == "latency" && aggregation != "max") {
histograms = "COALESCE(histogram_deltas, '')"
}
if group == "" || group == "requests" || (group == "overview" && aggregation != "max") {
requests = "COALESCE(request_deltas, '')"
}
if histograms == "''" && requests == "''" {
return ctx.Err()
}
bucket := fmt.Sprintf("(CAST(strftime('%%s', created_at) AS INTEGER) - %d) / %d", start.Unix(), seconds)
rows, err := global.VLLMMonitorDB.WithContext(ctx).Model(&model.MonitorVLLM{}).
Where("app_install_id = ? AND created_at >= ? AND created_at <= ?", id, start, end).
Select(bucket + ", " + histograms + ", " + requests).Rows()
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
if err := ctx.Err(); err != nil {
return err
}
var index int64
var histograms, requests string
if err := rows.Scan(&index, &histograms, &requests); err != nil {
return err
}
if err := visit(index, histograms, requests); err != nil {
return err
}
}
return rows.Err()
}
+65
View File
@@ -1,11 +1,13 @@
package repo package repo
import ( import (
"context"
"errors" "errors"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type SettingRepo struct{} type SettingRepo struct{}
@@ -14,17 +16,22 @@ type ISettingRepo interface {
GetList(opts ...DBOption) ([]model.Setting, error) GetList(opts ...DBOption) ([]model.Setting, error)
Get(opts ...DBOption) (model.Setting, error) Get(opts ...DBOption) (model.Setting, error)
GetValueByKey(key string) (string, error) GetValueByKey(key string) (string, error)
GetCoreValueByKey(key string) (string, error)
Create(key, value string) error Create(key, value string) error
Update(key, value string) error Update(key, value string) error
WithByKey(key string) DBOption WithByKey(key string) DBOption
UpdateOrCreate(key, value string) error UpdateOrCreate(key, value string) error
UpdateValues(map[string]string) error
GetDescription(opts ...DBOption) (model.CommonDescription, error) GetDescription(opts ...DBOption) (model.CommonDescription, error)
GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error) GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error)
CreateDescription(data *model.CommonDescription) error CreateDescription(data *model.CommonDescription) error
SaveDescriptions(context.Context, []model.CommonDescription) error
UpdateDescription(id string, val map[string]interface{}) error UpdateDescription(id string, val map[string]interface{}) error
DelDescription(id string) error DelDescription(id string) error
DeleteDescriptions(context.Context, string, []string, bool) (int64, error)
WithDescriptionIDs(ids []string) DBOption
WithByDescriptionID(id string) DBOption WithByDescriptionID(id string) DBOption
} }
@@ -68,6 +75,17 @@ func (s *SettingRepo) GetValueByKey(key string) (string, error) {
return setting.Value, nil return setting.Value, nil
} }
func (s *SettingRepo) GetCoreValueByKey(key string) (string, error) {
if global.CoreDB == nil {
return "", errors.New("core database is unavailable")
}
var setting model.Setting
if err := global.CoreDB.Where("key = ?", key).First(&setting).Error; err != nil {
return "", err
}
return setting.Value, nil
}
func (s *SettingRepo) WithByKey(key string) DBOption { func (s *SettingRepo) WithByKey(key string) DBOption {
return func(g *gorm.DB) *gorm.DB { return func(g *gorm.DB) *gorm.DB {
return g.Where("key = ?", key) return g.Where("key = ?", key)
@@ -90,6 +108,25 @@ func (s *SettingRepo) UpdateOrCreate(key, value string) error {
return global.DB.Model(&setting).UpdateColumn("value", value).Error return global.DB.Model(&setting).UpdateColumn("value", value).Error
} }
func (s *SettingRepo) UpdateValues(values map[string]string) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
for key, value := range values {
var setting model.Setting
err := tx.Where("key = ?", key).First(&setting).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
return err
}
} else if err != nil {
return err
} else if err := tx.Model(&setting).UpdateColumn("value", value).Error; err != nil {
return err
}
}
return nil
})
}
func (s *SettingRepo) GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error) { func (s *SettingRepo) GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error) {
var lists []model.CommonDescription var lists []model.CommonDescription
db := global.DB.Model(&model.CommonDescription{}) db := global.DB.Model(&model.CommonDescription{})
@@ -111,14 +148,42 @@ func (s *SettingRepo) GetDescription(opts ...DBOption) (model.CommonDescription,
func (s *SettingRepo) CreateDescription(data *model.CommonDescription) error { func (s *SettingRepo) CreateDescription(data *model.CommonDescription) error {
return global.DB.Create(data).Error return global.DB.Create(data).Error
} }
func (s *SettingRepo) SaveDescriptions(ctx context.Context, descriptions []model.CommonDescription) error {
return global.DB.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"description"}),
}).CreateInBatches(&descriptions, 100).Error
}
func (s *SettingRepo) UpdateDescription(id string, val map[string]interface{}) error { func (s *SettingRepo) UpdateDescription(id string, val map[string]interface{}) error {
return global.DB.Model(&model.CommonDescription{}).Where("id = ?", id).Updates(val).Error return global.DB.Model(&model.CommonDescription{}).Where("id = ?", id).Updates(val).Error
} }
func (s *SettingRepo) DelDescription(id string) error { func (s *SettingRepo) DelDescription(id string) error {
return global.DB.Where("id = ?", id).Delete(&model.CommonDescription{}).Error return global.DB.Where("id = ?", id).Delete(&model.CommonDescription{}).Error
} }
func (s *SettingRepo) DeleteDescriptions(ctx context.Context, kind string, ids []string, emptyOnly bool) (int64, error) {
var deleted int64
for start := 0; start < len(ids); start += 500 {
query := global.DB.WithContext(ctx).Where("type = ? AND id IN ?", kind, ids[start:min(start+500, len(ids))])
if emptyOnly {
query = query.Where("description = ? AND is_pinned = ?", "", false)
}
result := query.Delete(&model.CommonDescription{})
deleted += result.RowsAffected
if result.Error != nil {
return deleted, result.Error
}
}
return deleted, nil
}
func (s *SettingRepo) WithByDescriptionID(id string) DBOption { func (s *SettingRepo) WithByDescriptionID(id string) DBOption {
return func(g *gorm.DB) *gorm.DB { return func(g *gorm.DB) *gorm.DB {
return g.Where("id = ?", id) return g.Where("id = ?", id)
} }
} }
func (s *SettingRepo) WithDescriptionIDs(ids []string) DBOption {
return func(db *gorm.DB) *gorm.DB { return db.Where("id IN ?", ids) }
}
+4 -1
View File
@@ -2,6 +2,7 @@ package repo
import ( import (
"context" "context"
"time"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
@@ -110,7 +111,9 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
} }
func (t TaskRepo) UpdateRunningTaskToFailed() error { func (t TaskRepo) UpdateRunningTaskToFailed() error {
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{"status": constant.StatusFailed, "error_msg": "1Panel restart causes failure"}).Error return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
}).Error
} }
func (t TaskRepo) CountExecutingTask() (int64, error) { func (t TaskRepo) CountExecutingTask() (int64, error) {
+55 -6
View File
@@ -142,6 +142,9 @@ const (
func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) { func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
agentType := req.AgentType agentType := req.AgentType
if err := validateNativeAgentCreate(req); err != nil {
return nil, err
}
if err := checkPortExist(req.WebUIPort); err != nil { if err := checkPortExist(req.WebUIPort); err != nil {
return nil, err return nil, err
} }
@@ -237,6 +240,8 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
} }
} else if agentType == constant.AppCopaw { } else if agentType == constant.AppCopaw {
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword) dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
} else if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOctop {
dashboardAuth = agentDashboardAuth{Username: req.DashboardUsername, Password: req.DashboardPassword}
} }
params := map[string]interface{}{ params := map[string]interface{}{
@@ -245,6 +250,12 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
constant.HostIP: "", constant.HostIP: "",
} }
setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort) setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort)
if agentType == constant.AppDeepSeekHarness {
params["HTTPS_ACCESS_HOST"] = req.AccessHost
}
if agentType == constant.AppOctop {
params["OCTOP_LOG_LEVEL"] = "info"
}
if agentType == constant.AppOpenclaw { if agentType == constant.AppOpenclaw {
if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" { if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
params["ALLOWED_ORIGIN"] = allowedOrigin params["ALLOWED_ORIGIN"] = allowedOrigin
@@ -738,7 +749,7 @@ func requiresAgentAccount(agentType string) bool {
} }
func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) { func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) {
if agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) { if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
params["PANEL_APP_PORT_HTTPS"] = webUIPort params["PANEL_APP_PORT_HTTPS"] = webUIPort
return return
} }
@@ -1044,7 +1055,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
return err return err
} }
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability) resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, false)
if err != nil { if err != nil {
return err return err
} }
@@ -1097,12 +1108,15 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if strings.TrimSpace(requestedVerifyModel) == "" { if strings.TrimSpace(requestedVerifyModel) == "" {
requestedVerifyModel = account.VerifyModel requestedVerifyModel = account.VerifyModel
} }
verifyModel, err := resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models) verifyModel := ""
if account.AppInstallID == 0 || len(models) > 0 {
verifyModel, err = resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
if err != nil { if err != nil {
return err return err
} }
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability) resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, account.AppInstallID != 0 && len(models) == 0)
if err != nil { if err != nil {
return err return err
} }
@@ -1116,7 +1130,7 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account.Remark = req.Remark account.Remark = req.Remark
account.Verified = true account.Verified = true
if err := global.DB.Save(account).Error; err != nil { if err := agentAccountRepo.Save(account); err != nil {
return err return err
} }
terminalai.InvalidateTerminalRuntimeCache() terminalai.InvalidateTerminalRuntimeCache()
@@ -1147,13 +1161,34 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
installIDs := make([]uint, 0, len(list))
for _, account := range list {
if account.AppInstallID != 0 {
installIDs = append(installIDs, account.AppInstallID)
}
}
localInstalls := make(map[uint]bool, len(installIDs))
if len(installIDs) > 0 {
installs, err := appInstallRepo.ListBy(context.Background(), repo.WithByIDs(installIDs))
if err != nil {
return 0, nil, err
}
for _, install := range installs {
localInstalls[install.ID] = true
}
}
items := make([]dto.AgentAccountInfo, 0, len(list)) items := make([]dto.AgentAccountInfo, 0, len(list))
for _, item := range list { for _, item := range list {
apiKey := "" apiKey := ""
if item.RememberAPIKey { if item.RememberAPIKey {
apiKey = item.APIKey apiKey = item.APIKey
} }
appInstallID := uint(0)
if localInstalls[item.AppInstallID] {
appInstallID = item.AppInstallID
}
items = append(items, dto.AgentAccountInfo{ items = append(items, dto.AgentAccountInfo{
AppInstallID: appInstallID,
ID: item.ID, ID: item.ID,
MasterAccountID: item.MasterAccountID, MasterAccountID: item.MasterAccountID,
Provider: item.Provider, Provider: item.Provider,
@@ -1351,13 +1386,27 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
} }
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error { func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true) _, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true, false)
return err return err
} }
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error { func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock() agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock() defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
if account.AppInstallID != 0 && account.Provider != "ollama" {
_, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
if err == nil {
return buserr.WithDetail("ErrInvalidParams", "Delete the local inference engine instead", nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if err := global.DB.Transaction(func(tx *gorm.DB) error { if err := global.DB.Transaction(func(tx *gorm.DB) error {
var agentCount int64 var agentCount int64
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil { if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
+6 -2
View File
@@ -8,11 +8,15 @@ import (
) )
const hermesWeixinLoginScript = `import asyncio const hermesWeixinLoginScript = `import asyncio
from gateway.platforms.weixin import qr_login try:
from plugins.platforms.weixin.adapter import qr_login
except ModuleNotFoundError as exc:
if exc.name not in ('plugins', 'plugins.platforms', 'plugins.platforms.weixin', 'plugins.platforms.weixin.adapter'):
raise
from gateway.platforms.weixin import qr_login
from hermes_cli.config import save_env_value from hermes_cli.config import save_env_value
creds = asyncio.run(qr_login('/opt/data')) creds = asyncio.run(qr_login('/opt/data'))
print('RESULT =', creds)
if not creds: if not creds:
raise SystemExit(1) raise SystemExit(1)
+202
View File
@@ -0,0 +1,202 @@
package service
import (
"errors"
"fmt"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
"gorm.io/gorm"
)
func SyncLocalModelAccount(install model.AppInstall, accountID uint, baseURL string, models []dto.AgentAccountModel, updateBaseURL, createIfMissing bool) (*model.AgentAccount, error) {
if install.ID == 0 || (install.App.Key != "ollama" && install.App.Key != "vllm") {
return nil, fmt.Errorf("invalid local model installation")
}
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if errors.Is(err, gorm.ErrRecordNotFound) && accountID != 0 {
account, err = agentAccountRepo.GetFirst(repo.WithByID(accountID), repo.WithByProvider(install.App.Key))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err == nil && account.AppInstallID != 0 && account.AppInstallID != install.ID {
return nil, fmt.Errorf("model account belongs to another installation")
}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
if !createIfMissing {
return nil, nil
}
name := install.Name
for suffix := 1; ; suffix++ {
existing, err := agentAccountRepo.List(repo.WithByProvider(install.App.Key), repo.WithByName(name))
if err != nil {
return nil, err
}
if len(existing) == 0 {
break
}
name = fmt.Sprintf("%s-%d", install.Name, suffix)
}
apiType, apiKey := "openai-completions", "vllm-local"
if install.App.Key == "ollama" {
apiType, apiKey = "openai-responses", "ollama-local"
}
account = &model.AgentAccount{Name: name, Provider: install.App.Key, BaseURL: baseURL, APIType: apiType, APIKey: apiKey, RememberAPIKey: true, Verified: true}
}
accountChanged := account.ID == 0 || account.AppInstallID != install.ID || (updateBaseURL && account.BaseURL != baseURL)
account.AppInstallID = install.ID
if updateBaseURL {
account.BaseURL = baseURL
}
rows := make([]model.AgentAccountModel, 0, len(models))
verifyModelExists := account.VerifyModel == ""
for _, item := range models {
rows = append(rows, model.AgentAccountModel{Model: item.ID, Name: item.Name})
if item.ID == account.VerifyModel {
verifyModelExists = true
}
}
if !verifyModelExists {
account.VerifyModel = ""
accountChanged = true
}
var previous []model.AgentAccountModel
if account.ID != 0 {
previous, err = agentAccountModelRepo.List(repo.WithByAccountID(account.ID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
}
modelsChanged := len(previous) != len(rows)
if !modelsChanged {
for i := range rows {
if previous[i].Model != rows[i].Model || previous[i].Name != rows[i].Name {
modelsChanged = true
break
}
}
}
if modelsChanged {
err = agentAccountRepo.SaveLocal(account, rows)
} else if accountChanged {
err = agentAccountRepo.Save(account)
} else {
return account, nil
}
if err != nil {
return nil, err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return account, nil
}
func SyncOllamaModelAccount(createIfMissing bool) error {
app, err := appRepo.GetFirst(appRepo.WithKey("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
install, err := appInstallRepo.GetFirst(appInstallRepo.WithAppId(app.ID))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if !install.SyncModelAccount {
return nil
}
return syncOllamaModelAccount(install, fmt.Sprintf("http://%s:11434/v1", install.ContainerName), false, createIfMissing)
}
func syncOllamaModelAccount(install model.AppInstall, baseURL string, updateBaseURL, createIfMissing bool) error {
models, err := aiRepo.List(repo.WithByStatus(constant.StatusSuccess), repo.WithOrderAsc("name"))
if err != nil {
return err
}
items := make([]dto.AgentAccountModel, 0, len(models))
for _, item := range models {
items = append(items, dto.AgentAccountModel{ID: item.Name, Name: item.Name})
}
_, err = SyncLocalModelAccount(install, 0, baseURL, items, updateBaseURL, createIfMissing)
return err
}
func loadOllamaModelAccountAddress(install model.AppInstall) (string, string, error) {
addressType := install.ModelAccountBaseURLType
if addressType == "" {
addressType = "container"
}
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID), repo.WithByProvider("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return addressType, install.ModelAccountBaseURL, nil
}
if err != nil {
return "", "", err
}
if addressType != "custom" {
expectedURL, err := ResolveLocalModelAccountBaseURL(addressType, "", install.ContainerName, "11434", install.HttpPort)
if err != nil {
return "", "", err
}
if strings.TrimRight(account.BaseURL, "/") != expectedURL {
addressType = "custom"
}
}
return addressType, account.BaseURL, nil
}
func CleanupLocalModelAccount(installID uint) {
if installID == 0 {
return
}
accounts, err := agentAccountRepo.List(repo.WithByAppInstallID(installID))
if err != nil {
global.LOG.Errorf("load local model accounts: %v", err)
return
}
for _, account := range accounts {
if err := (AgentService{}).DeleteAccount(dto.AgentAccountDeleteReq{ID: account.ID}); err != nil {
global.LOG.Warnf("keep local model account %d after uninstall: %v", account.ID, err)
}
}
}
func ResolveLocalModelAccountBaseURL(addressType, customURL, containerName, containerPort string, hostPort int) (string, error) {
switch addressType {
case "custom":
baseURL := strings.TrimRight(strings.TrimSpace(customURL), "/")
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return "", fmt.Errorf("model account base URL must start with http:// or https://")
}
return baseURL, nil
case "localhost":
return fmt.Sprintf("http://127.0.0.1:%d/v1", hostPort), nil
case "systemIP":
systemIP, err := settingRepo.GetValueByKey("SystemIP")
if err != nil {
return "", err
}
systemIP = strings.TrimSpace(systemIP)
if systemIP == "" {
systemIP = "127.0.0.1"
}
return fmt.Sprintf("http://%s:%d/v1", systemIP, hostPort), nil
default:
return fmt.Sprintf("http://%s:%s/v1", containerName, containerPort), nil
}
}
+115
View File
@@ -0,0 +1,115 @@
package service
import (
"encoding/json"
"path/filepath"
"reflect"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
)
func TestMiniMaxOpenclawModelMetadata(t *testing.T) {
for _, provider := range []string{"minimax", "custom", "bailian-coding-plan"} {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5", "saved-custom"} {
t.Run(provider+"/"+protocol+"/"+id, func(t *testing.T) {
account := &model.AgentAccount{Provider: provider, APIType: protocol, BaseURL: "https://example.test/v1", APIKey: "test-key"}
_, entry, _, _, err := buildOpenclawAccountModelConfig(account, dto.AgentAccountModel{ID: id, Name: "Saved name"})
if err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(entry)
if err != nil {
t.Fatal(err)
}
var actual modelEntry
if err := json.Unmarshal(payload, &actual); err != nil {
t.Fatal(err)
}
if actual.ID != id || actual.Name != "Saved name" {
t.Fatalf("changed saved identity: %s", payload)
}
if provider == "minimax" && id == "MiniMax-M3.1-Flash" {
if actual.ContextWindow != 1000000 || actual.MaxTokens != 524288 || !reflect.DeepEqual(actual.Input, []string{"text", "image"}) {
t.Fatalf("incorrect direct Flash metadata: %s", payload)
}
} else if actual.ContextWindow != 0 || actual.MaxTokens != 0 {
t.Fatalf("changed existing/gateway limits: %s", payload)
}
})
}
}
}
}
func TestMiniMaxPreservesSavedOpenclawMetadata(t *testing.T) {
current := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text"}, ContextWindow: 32000, MaxTokens: 4096}}},
}}
conf := map[string]interface{}{}
if err := applyOpenclawModelsConfig(conf, current); err != nil {
t.Fatal(err)
}
next := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text", "image"}, ContextWindow: 1000000, MaxTokens: 524288}}},
}}
preserveOpenclawModelMetadata(conf, next)
if !reflect.DeepEqual(next.Providers["minimax"].Models, current.Providers["minimax"].Models) {
t.Fatalf("saved metadata changed: %+v", next)
}
}
func TestMiniMaxDefaultAccountRequest(t *testing.T) {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
account := &model.AgentAccount{Provider: "minimax", APIType: protocol}
models, err := buildInitialAgentAccountModels(account, nil)
if err != nil {
t.Fatal(err)
}
selected, err := resolveAgentAccountVerifyModel("minimax", "", models)
if err != nil || selected != "MiniMax-M3.1-Flash" || len(models) != 2 || models[1].ID != "MiniMax-M3" {
t.Fatalf("unexpected default: %s, %+v, %v", selected, models, err)
}
request := providercatalog.BuildVerifyRequest("minimax", protocol, "", "https://example.test", "test-key", selected)
var body map[string]interface{}
if err := json.Unmarshal(request.Body, &body); err != nil || body["model"] != selected {
t.Fatalf("default request: %s, %v", request.Body, err)
}
}
}
func TestMiniMaxHermesConfiguration(t *testing.T) {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "my-custom-model"} {
dir := t.TempDir()
account := &model.AgentAccount{Provider: "minimax", APIType: protocol, BaseURL: "https://api.minimaxi.com/anthropic", APIKey: "test-key"}
if err := writeHermesConfig(dir, account, id, ""); err != nil {
t.Fatal(err)
}
conf, err := readHermesConfig(filepath.Join(dir, "config.yaml"))
if err != nil {
t.Fatal(err)
}
if conf.Model.Default != id || conf.Model.Provider != "minimax-cn" || conf.Model.BaseURL != account.BaseURL {
t.Fatalf("changed Hermes selection: %+v", conf.Model)
}
}
}
}
func TestMiniMaxExplicitAccountModels(t *testing.T) {
for _, provider := range []string{"minimax", "bailian-coding-plan", "custom"} {
account := &model.AgentAccount{Provider: provider, APIType: "openai-completions"}
requested := []dto.AgentAccountModel{{ID: "MiniMax-M2.5", Name: "Saved legacy"}, {ID: "my-custom-model", Name: "Saved custom"}}
actual, err := buildInitialAgentAccountModels(account, requested)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(actual, requested) {
t.Fatalf("changed explicit %s models: %+v", provider, actual)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"net"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
)
var deepSeekUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var deepSeekHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
func isDeepSeekAccessHost(value string) bool {
if ip := net.ParseIP(value); ip != nil {
return ip.To4() != nil && !strings.Contains(value, ":")
}
if value == "" || len(value) > 253 || strings.Trim(value, "0123456789.") == "" {
return false
}
for _, label := range strings.Split(value, ".") {
if !deepSeekHostLabelPattern.MatchString(label) {
return false
}
}
return true
}
func validateNativeAgentCreate(req dto.AgentCreateReq) error {
if req.AgentType != constant.AppDeepSeekHarness && req.AgentType != constant.AppOctop {
return nil
}
if strings.TrimSpace(req.DashboardUsername) == "" || strings.ContainsAny(req.DashboardUsername, "\r\n\x00") {
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
}
if strings.ContainsAny(req.DashboardPassword, "\r\n\x00") || req.DashboardPassword != strings.TrimSpace(req.DashboardPassword) {
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
if req.AgentType == constant.AppDeepSeekHarness {
if !isDeepSeekAccessHost(req.AccessHost) {
return buserr.WithDetail("ErrInvalidParams", "accessHost", nil)
}
if !deepSeekUsernamePattern.MatchString(req.DashboardUsername) {
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
}
if utf8.RuneCountInString(req.DashboardPassword) < 12 {
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
return nil
}
hasLetter, hasDigit := false, false
for _, char := range req.DashboardPassword {
hasLetter = hasLetter || unicode.IsLetter(char)
hasDigit = hasDigit || unicode.IsDigit(char)
}
if utf8.RuneCountInString(req.DashboardPassword) < 8 || !hasLetter || !hasDigit {
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
switch strings.ToLower(req.DashboardPassword) {
case "password", "password1", "password12", "password123", "12345678", "123456789", "qwerty123",
"admin123", "welcome1", "letmein1", "changeme1", "octop123", "abc12345", "iloveyou1":
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
return nil
}
+27 -8
View File
@@ -98,7 +98,7 @@ func ensureContainerRunning(containerName string) error {
return nil return nil
} }
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) { func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability, allowEmptyModel bool) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey) resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedAPIType := strings.TrimSpace(apiType) resolvedAPIType := strings.TrimSpace(apiType)
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode) resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
@@ -113,7 +113,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
return resolvedAgentAccountInput{}, err return resolvedAgentAccountInput{}, err
} }
modelID = strings.TrimSpace(modelID) modelID = strings.TrimSpace(modelID)
if modelID == "" { if modelID == "" && !allowEmptyModel {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired") return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
} }
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType) imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
@@ -415,6 +415,15 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
} else { } else {
item.WebUIPort = appInstall.HttpPort item.WebUIPort = appInstall.HttpPort
} }
if agentType == constant.AppDeepSeekHarness {
item.WebUIPort = appInstall.HttpsPort
if values, err := readAgentEnvMap(appInstall.GetEnvPath()); err == nil {
accessHost := values["HTTPS_ACCESS_HOST"]
if isDeepSeekAccessHost(accessHost) {
item.AccessHost = accessHost
}
}
}
item.Path = appInstall.GetPath() item.Path = appInstall.GetPath()
item.Status = appInstall.Status item.Status = appInstall.Status
item.Message = appInstall.Message item.Message = appInstall.Message
@@ -442,7 +451,8 @@ func localizedAgentProviderName(provider string) string {
} }
func isAgentAppKey(appKey string) bool { func isAgentAppKey(appKey string) bool {
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent ||
appKey == constant.AppDeepSeekHarness || appKey == constant.AppOctop
} }
func isOpenclawLegacyHTTPVersion(version string) bool { func isOpenclawLegacyHTTPVersion(version string) bool {
@@ -652,7 +662,7 @@ func checkAgentUpgradable(install model.AppInstall) bool {
if len(ignores) > 0 { if len(ignores) > 0 {
continue continue
} }
if common.IsCrossVersion(install.Version, item.Version) && !install.App.CrossVersionUpdate { if isCrossAppVersion(install.App.Key, install.Version, item.Version) && !install.App.CrossVersionUpdate {
continue continue
} }
versions = append(versions, item.Version) versions = append(versions, item.Version)
@@ -660,12 +670,12 @@ func checkAgentUpgradable(install model.AppInstall) bool {
if len(versions) == 0 { if len(versions) == 0 {
return false return false
} }
versions = common.GetSortedVersions(versions) versions = sortAppVersions(install.App.Key, versions)
lastVersion := versions[0] lastVersion := versions[0]
if common.IsCrossVersion(install.Version, lastVersion) { if isCrossAppVersion(install.App.Key, install.Version, lastVersion) {
return install.App.CrossVersionUpdate return install.App.CrossVersionUpdate
} }
return common.CompareVersion(lastVersion, install.Version) return isNewerAppVersion(install.App.Key, lastVersion, install.Version)
} }
type openclawConfig struct { type openclawConfig struct {
@@ -1253,7 +1263,12 @@ func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.Agen
if err != nil { if err != nil {
return "", modelEntry{}, "", modelProvider{}, err return "", modelEntry{}, "", modelProvider{}, err
} }
return providerPatch.PrimaryModel, buildOpenclawModelEntry(providerPatch.ModelID, model), providerPatch.ProviderKey, modelProvider{ entry := buildOpenclawModelEntry(providerPatch.ModelID, model)
if account.Provider == "minimax" && providerPatch.ModelID == "MiniMax-M3.1-Flash" {
entry.ContextWindow = 1000000
entry.MaxTokens = 524288
}
return providerPatch.PrimaryModel, entry, providerPatch.ProviderKey, modelProvider{
ApiKey: providerPatch.APIKey, ApiKey: providerPatch.APIKey,
BaseUrl: providerPatch.BaseURL, BaseUrl: providerPatch.BaseURL,
Api: providerPatch.APIType, Api: providerPatch.APIType,
@@ -1641,6 +1656,10 @@ func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
case constant.AppCopaw: case constant.AppCopaw:
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
case constant.AppDeepSeekHarness:
return "DSH_AUTH_USERNAME", "DSH_AUTH_PASSWORD", true
case constant.AppOctop:
return "OCTOP_ADMIN_USERNAME", "OCTOP_DEFAULT_PASSWORD", true
default: default:
return "", "", false return "", "", false
} }
+107 -47
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path" "path"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -28,13 +29,15 @@ import (
type AIToolService struct{} type AIToolService struct{}
var ollamaModelMu sync.Mutex
type IAIToolService interface { type IAIToolService interface {
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
Create(req dto.OllamaModelName) error Create(req dto.OllamaModelName) error
Close(name string) error Close(name string) error
Recreate(req dto.OllamaModelName) error Recreate(req dto.OllamaModelName) error
Delete(req dto.ForceDelete) error Delete(req dto.ForceDelete) error
Sync() ([]dto.OllamaModelDropList, error) SyncAccount(req dto.OllamaModelAccountSync) error
LoadDetail(name string) (string, error) LoadDetail(name string) (string, error)
BindDomain(req dto.OllamaBindDomain) error BindDomain(req dto.OllamaBindDomain) error
GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error) GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error)
@@ -46,6 +49,7 @@ func NewIAIToolService() IAIToolService {
} }
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) { func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
syncErr := syncOllamaModels()
var options []repo.DBOption var options []repo.DBOption
if len(req.Info) != 0 { if len(req.Info) != 0 {
options = append(options, repo.WithByLikeName(req.Info)) options = append(options, repo.WithByLikeName(req.Info))
@@ -60,6 +64,11 @@ func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModel
if err := copier.Copy(&item, &itemModel); err != nil { if err := copier.Copy(&item, &itemModel); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil) return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
} }
if syncErr != nil && item.Status != constant.StatusWaiting {
item.Status = constant.StatusUnknown
item.Message = syncErr.Error()
item.Size = ""
}
taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI)) taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI))
if len(taskModel.ID) != 0 { if len(taskModel.ID) != 0 {
item.LogFileExist = true item.LogFileExist = true
@@ -85,6 +94,8 @@ func (u *AIToolService) LoadDetail(name string) (string, error) {
} }
func (u *AIToolService) Create(req dto.OllamaModelName) error { func (u *AIToolService) Create(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) { if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -125,6 +136,9 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil { if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) _ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
} }
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}() }()
return nil return nil
} }
@@ -144,6 +158,8 @@ func (u *AIToolService) Close(name string) error {
} }
func (u *AIToolService) Recreate(req dto.OllamaModelName) error { func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) { if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -179,11 +195,16 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil { if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) _ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
} }
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}() }()
return nil return nil
} }
func (u *AIToolService) Delete(req dto.ForceDelete) error { func (u *AIToolService) Delete(req dto.ForceDelete) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs)) ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
if len(ollamaList) == 0 { if len(ollamaList) == 0 {
return buserr.New("ErrRecordNotFound") return buserr.New("ErrRecordNotFound")
@@ -202,55 +223,28 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name) logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
_ = os.Remove(logItem) _ = os.Remove(logItem)
} }
return nil return SyncOllamaModelAccount(false)
} }
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) { func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
containerName, err := LoadContainerName() install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
if err != nil { if err != nil {
return nil, err return err
} }
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list") if install.App.Key != "ollama" {
return buserr.New("ErrInvalidParams")
}
baseURL, err := ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, install.ContainerName, "11434", install.HttpPort)
if err != nil { if err != nil {
return nil, err return err
} }
var list []model.OllamaModel if err := syncOllamaModelAccount(install, baseURL, true, true); err != nil {
lines := strings.Split(stdout, "\n") return err
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) < 5 {
continue
} }
if parts[0] == "NAME" { return appInstallRepo.BatchUpdateBy(map[string]interface{}{
continue "model_account_base_url_type": req.ModelAccountBaseURLType,
} "model_account_base_url": baseURL,
list = append(list, model.OllamaModel{Name: parts[0], Size: parts[2] + " " + parts[3]}) }, repo.WithByID(install.ID))
}
listInDB, _ := aiRepo.List()
var dropList []dto.OllamaModelDropList
for _, itemModel := range listInDB {
isExit := false
for i := 0; i < len(list); i++ {
if list[i].Name == itemModel.Name {
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusSuccess, "message": "", "size": list[i].Size})
list = append(list[:i], list[(i+1):]...)
isExit = true
break
}
}
if !isExit && itemModel.Status != constant.StatusWaiting {
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusDeleted, "message": "not exist", "size": ""})
dropList = append(dropList, dto.OllamaModelDropList{ID: itemModel.ID, Name: itemModel.Name})
continue
}
}
for _, item := range list {
item.Status = constant.StatusSuccess
item.From = "remote"
_ = aiRepo.Create(&item)
}
return dropList, nil
} }
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error { func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
@@ -370,6 +364,72 @@ func (u *AIToolService) UpdateBindDomain(req dto.OllamaBindDomain) error {
return nil return nil
} }
func syncOllamaModels() error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
containerName, err := LoadContainerName()
if err != nil {
return err
}
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
if err != nil {
return err
}
models := make(map[string]string)
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "NAME") {
return fmt.Errorf("invalid ollama list output: %s", stdout)
}
for _, line := range lines[1:] {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 5 {
return fmt.Errorf("invalid ollama model entry: %s", line)
}
models[parts[0]] = parts[2] + " " + parts[3]
}
list, err := aiRepo.List()
if err != nil {
return err
}
known := make(map[string]bool, len(list))
for _, item := range list {
name := item.Name
if !strings.Contains(path.Base(name), ":") {
name += ":latest"
}
size, exists := models[name]
known[name] = true
if item.Status == constant.StatusWaiting {
continue
}
status, message := constant.StatusSuccess, ""
if !exists {
if item.Status == constant.StatusFailed || item.Status == constant.StatusCanceled {
continue
}
status, message = constant.StatusDeleted, "not exist"
}
if item.Status != status || item.Size != size || item.Message != message {
if err := aiRepo.Update(item.ID, map[string]interface{}{"status": status, "size": size, "message": message}); err != nil {
return err
}
}
}
for name, size := range models {
if known[name] {
continue
}
item := model.OllamaModel{Name: name, Size: size, From: "remote", Status: constant.StatusSuccess}
if err := aiRepo.Create(&item); err != nil {
return err
}
}
return nil
}
func LoadContainerName() (string, error) { func LoadContainerName() (string, error) {
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "") ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
if err != nil { if err != nil {
@@ -387,12 +447,12 @@ func loadModelSize(name string, containerName string) (string, error) {
return "", err return "", err
} }
lines := strings.Split(stdout, "\n") lines := strings.Split(stdout, "\n")
for _, line := range lines { if !strings.Contains(path.Base(name), ":") {
if !strings.Contains(line, name) { name += ":latest"
continue
} }
for _, line := range lines {
parts := strings.Fields(line) parts := strings.Fields(line)
if len(parts) < 5 { if len(parts) < 5 || parts[0] != name {
continue continue
} }
return parts[2] + " " + parts[3], nil return parts[2] + " " + parts[3], nil
+209
View File
@@ -0,0 +1,209 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/mattn/go-shellwords"
)
func AIBenchmarkSources(kind, selected string) ([]dto.AIBenchmarkSource, error) {
if kind != "vllm" && kind != "ollama" {
return nil, errors.New("unsupported benchmark source")
}
selectedID := strings.SplitN(selected, ":", 2)[0]
type instance struct {
id uint
name, container string
port int
}
instances := []instance{}
apps, err := appRepo.GetBy(appRepo.WithKey(kind))
if err != nil {
return nil, err
}
for _, app := range apps {
installs, err := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(app.ID))
if err != nil {
return nil, err
}
for _, install := range installs {
if selected != "" && strconv.FormatUint(uint64(install.ID), 10) != selectedID {
continue
}
instances = append(instances, instance{id: install.ID, name: install.Name, container: install.ContainerName, port: install.HttpPort})
}
}
results := []dto.AIBenchmarkSource{}
if len(instances) == 0 {
return results, nil
}
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
if !strings.HasPrefix(client.DaemonHost(), "unix://") {
return nil, errors.New("benchmark shortcuts require a Docker daemon on the main node; enter the remote service address manually")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for _, instance := range instances {
base := dto.AIBenchmarkSource{Kind: kind, Name: instance.name, Endpoint: "/v1/chat/completions", Models: []string{}, Status: "unknown"}
inspected, err := client.ContainerInspect(ctx, instance.container)
if err != nil {
base.ID = strconv.FormatUint(uint64(instance.id), 10)
base.Message = "container inspection unavailable"
results = append(results, base)
continue
}
if inspected.State != nil {
base.Status = inspected.State.Status
}
args := []string{}
if inspected.Config != nil {
args = append(args, inspected.Config.Entrypoint...)
args = append(args, inspected.Config.Cmd...)
}
if len(args) >= 3 && (args[0] == "sh" || args[0] == "bash" || strings.HasSuffix(args[0], "/sh") || strings.HasSuffix(args[0], "/bash")) && args[1] == "-c" {
parser := shellwords.NewParser()
parser.ParseEnv = false
parser.ParseBacktick = false
if parsed, err := parser.Parse(args[2]); err == nil {
args = parsed
}
}
flags := map[string]string{}
for i, arg := range args {
if key, value, ok := strings.Cut(arg, "="); ok && strings.HasPrefix(key, "--") {
flags[key] = value
} else if strings.HasPrefix(arg, "--") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
flags[arg] = args[i+1]
}
}
if name := flags["--served-model-name"]; name != "" {
base.Models = []string{name}
}
tokenizer := flags["--tokenizer"]
if tokenizer == "" {
tokenizer = flags["--model"]
for i, arg := range args {
if tokenizer == "" && arg == "serve" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
tokenizer = args[i+1]
}
}
}
if kind != "ollama" && tokenizer != "" {
if !filepath.IsAbs(tokenizer) {
base.Tokenizer = tokenizer
} else {
longest := 0
for _, mount := range inspected.Mounts {
relative, err := filepath.Rel(mount.Destination, tokenizer)
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && len(mount.Destination) > longest {
candidate := filepath.Join(mount.Source, relative)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
base.Tokenizer = candidate
longest = len(mount.Destination)
}
}
}
}
}
ports := map[int]string{}
if instance.port > 0 {
ports[instance.port] = "127.0.0.1"
}
if inspected.NetworkSettings != nil {
for _, bindings := range inspected.NetworkSettings.Ports {
for _, binding := range bindings {
port, _ := strconv.Atoi(binding.HostPort)
if port > 0 {
host := binding.HostIP
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
ports[port] = host
}
}
}
}
if len(ports) == 0 && inspected.HostConfig != nil && inspected.HostConfig.NetworkMode == "host" {
port, _ := strconv.Atoi(flags["--port"])
if port > 0 {
ports[port] = "127.0.0.1"
}
}
ordered := []int{}
for port := range ports {
ordered = append(ordered, port)
}
sort.Ints(ordered)
if len(ordered) == 0 {
ordered = append(ordered, 0)
}
for _, port := range ordered {
source := base
source.ID = fmt.Sprintf("%d:%d", instance.id, port)
if selected != "" && source.ID != selected {
continue
}
if port > 0 {
source.BaseURL = "http://" + net.JoinHostPort(ports[port], strconv.Itoa(port))
}
if selected == source.ID && source.BaseURL != "" && source.Status == "running" {
endpoint := "/v1/models"
if kind == "ollama" {
endpoint = "/api/tags"
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.BaseURL+endpoint, nil)
if err != nil {
return nil, err
}
response, err := (&http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(request)
if err != nil {
source.Message = "model list unavailable; enter the served model name"
} else {
raw, readErr := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024))
response.Body.Close()
if response.StatusCode == http.StatusOK && readErr == nil {
var data struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if err := json.Unmarshal(raw, &data); err == nil {
source.Models = []string{}
for _, model := range data.Data {
source.Models = append(source.Models, model.ID)
}
for _, model := range data.Models {
source.Models = append(source.Models, model.Name)
}
}
} else {
source.Message = fmt.Sprintf("model list HTTP %d; provide model name and credentials if required", response.StatusCode)
}
}
}
results = append(results, source)
}
}
return results, nil
}
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/files"
)
func prepareAIGatewayAppRecover(install *model.AppInstall, recoverFile, secret string) (string, func(), error) {
noop := func() {}
if !isAIGatewayApp(install.App.Key) {
return "", noop, nil
}
tmpDir, err := os.MkdirTemp(global.Dir.TmpDir, "ai-gateway-recover-")
if err != nil {
return "", noop, err
}
cleanup := func() { _ = os.RemoveAll(tmpDir) }
fail := func(err error) (string, func(), error) {
cleanup()
return "", noop, err
}
if err := files.NewFileOp().TarGzExtractPro(recoverFile, tmpDir, secret); err != nil {
return fail(err)
}
backupDir := filepath.Join(tmpDir, strings.ReplaceAll(filepath.Base(recoverFile), ".tar.gz", ""))
for _, name := range []string{"app.json", "app.tar.gz"} {
info, err := os.Lstat(filepath.Join(backupDir, name))
if err != nil || !info.Mode().IsRegular() {
return fail(buserr.New("AppBackupFileIncomplete"))
}
}
metadata, err := os.ReadFile(filepath.Join(backupDir, "app.json"))
if err != nil {
return fail(err)
}
var backup model.AppInstall
if err := json.Unmarshal(metadata, &backup); err != nil {
return fail(fmt.Errorf("unmarshal app.json failed: %w", err))
}
if backup.App.Key != install.App.Key || backup.Name != install.Name {
return fail(buserr.New("AppAttributesNotMatch"))
}
// Historical backups already include Version. Unsuffixed releases are
// SQLite; no new metadata field is required. Missing versions are invalid,
// not evidence that an archive contains SQLite data.
if strings.TrimSpace(backup.Version) == "" || strings.TrimSpace(install.Version) == "" {
return fail(buserr.New("AppBackupFileIncomplete"))
}
// Deliberately do not compare version order: restoring an older version of
// the same database type is required for failed-upgrade rollbacks.
if !isAIGatewayUpgradeVersionAllowed(install.App.Key, install.Version, backup.Version) {
return fail(buserr.New("ErrAIGatewayRecoverDatabaseTypeMismatch"))
}
return backupDir, cleanup, nil
}
+37
View File
@@ -0,0 +1,37 @@
package service
import (
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/utils/common"
)
const aiGatewayAppKey = "ai-gateway"
func isAIGatewayApp(appKey string) bool {
return appKey == aiGatewayAppKey || appKey == "local"+aiGatewayAppKey
}
func isAIGatewayPostgresVersion(version string) bool {
return strings.HasSuffix(version, "-pg") || strings.HasSuffix(version, "-postgres")
}
// Database variants are deployment choices, not interchangeable upgrades.
func isAIGatewayUpgradeVersionAllowed(appKey, currentVersion, targetVersion string) bool {
return !isAIGatewayApp(appKey) ||
isAIGatewayPostgresVersion(currentVersion) == isAIGatewayPostgresVersion(targetVersion)
}
func sortAIGatewayVersions(versions []string) {
sort.SliceStable(versions, func(i, j int) bool {
if common.CompareVersion(versions[i], versions[j]) {
return true
}
if common.CompareVersion(versions[j], versions[i]) {
return false
}
// Prefer SQLite only when the numeric versions are equal.
return !isAIGatewayPostgresVersion(versions[i]) && isAIGatewayPostgresVersion(versions[j])
})
}
+135 -135
View File
@@ -4,11 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"mime" "mime"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
@@ -17,14 +14,13 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
alertUtil "github.com/1Panel-dev/1Panel/agent/utils/alert"
alertconfig "github.com/1Panel-dev/1Panel/agent/utils/alert_config" alertconfig "github.com/1Panel-dev/1Panel/agent/utils/alert_config"
alertwebhook "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook" alertwebhook "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/copier" "github.com/1Panel-dev/1Panel/agent/utils/copier"
"github.com/1Panel-dev/1Panel/agent/utils/email" "github.com/1Panel-dev/1Panel/agent/utils/email"
"github.com/1Panel-dev/1Panel/agent/utils/xpack" "github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/1Panel-dev/1Panel/agent/utils/xpack/providers" "github.com/1Panel-dev/1Panel/agent/utils/xpack/providers"
"github.com/shirou/gopsutil/v4/disk"
) )
type AlertService struct{} type AlertService struct{}
@@ -78,10 +74,10 @@ type IAlertService interface {
GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertConfig, error) GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertConfig, error)
PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error)
UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error
UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) (string, error)
DeleteAlertConfig(id uint) error DeleteAlertConfig(id uint) (string, error)
TestAlertConfig(req dto.AlertConfigTest) (bool, error) TestAlertConfig(req dto.AlertConfigTest) (bool, error)
TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error) TestCustomAlertConfig(req dto.AlertConfigTest, lang string) (dto.AlertConfigTestResult, error)
} }
func NewIAlertService() IAlertService { func NewIAlertService() IAlertService {
@@ -109,7 +105,38 @@ func (a AlertService) PageAlert(search dto.AlertSearch) (int64, []dto.AlertDTO,
return 0, nil, err return 0, nil, err
} }
cronjobProjects := make(map[string]uint)
var cronjobIDs []uint
for _, item := range alerts { for _, item := range alerts {
if alertUtil.GetCronJobType(item.Type) != "cronJob" {
continue
}
if _, exists := cronjobProjects[item.Project]; exists {
continue
}
id, parseErr := strconv.ParseUint(item.Project, 10, strconv.IntSize)
if parseErr != nil || id == 0 {
continue
}
cronjobProjects[item.Project] = uint(id)
cronjobIDs = append(cronjobIDs, uint(id))
}
cronjobsByID := make(map[uint]model.Cronjob)
if len(cronjobIDs) > 0 {
cronjobs, err := cronjobRepo.List(repo.WithByIDs(cronjobIDs))
if err != nil {
return 0, nil, err
}
for _, cronjob := range cronjobs {
cronjobsByID[cronjob.ID] = cronjob
}
}
for _, item := range alerts {
var taskName string
if cronjob, exists := cronjobsByID[cronjobProjects[item.Project]]; exists && cronjob.Type == item.Type {
taskName = cronjob.Name
}
result = append(result, dto.AlertDTO{ result = append(result, dto.AlertDTO{
ID: item.ID, ID: item.ID,
@@ -119,6 +146,7 @@ func (a AlertService) PageAlert(search dto.AlertSearch) (int64, []dto.AlertDTO,
Method: item.Method, Method: item.Method,
Title: item.Title, Title: item.Title,
Project: item.Project, Project: item.Project,
TaskName: taskName,
Status: item.Status, Status: item.Status,
SendCount: item.SendCount, SendCount: item.SendCount,
AdvancedParams: item.AdvancedParams, AdvancedParams: item.AdvancedParams,
@@ -190,6 +218,16 @@ func (a AlertService) CreateAlert(create dto.AlertCreate, operator string) error
return err return err
} }
} else { } else {
advanced, err := prepareCronJobAlertParams(create.Type, "", create.AdvancedParams)
if err != nil {
return err
}
create.AdvancedParams = advanced
if create.Status != constant.AlertDisable {
if err := a.validateCronJobAlertChannels(create.Type, advanced, create.Method); err != nil {
return err
}
}
alertInfo.Status = constant.AlertEnable alertInfo.Status = constant.AlertEnable
if err := copier.Copy(&alertInfo, &create); err != nil { if err := copier.Copy(&alertInfo, &create); err != nil {
return buserr.WithErr("ErrStructTransform", err) return buserr.WithErr("ErrStructTransform", err)
@@ -207,11 +245,24 @@ func (a AlertService) CreateAlert(create dto.AlertCreate, operator string) error
} }
func (a AlertService) UpdateAlert(req dto.AlertUpdate, operator string) error { func (a AlertService) UpdateAlert(req dto.AlertUpdate, operator string) error {
if alertUtil.GetCronJobType(req.Type) == "cronJob" {
previous, err := alertRepo.Get(repo.WithByID(req.ID))
if err != nil {
return err
}
req.AdvancedParams, err = prepareCronJobAlertParams(req.Type, previous.AdvancedParams, req.AdvancedParams)
if err != nil {
return err
}
}
methodTypes, err := a.validateAlertMethodReferences(req.Method) methodTypes, err := a.validateAlertMethodReferences(req.Method)
if err != nil { if err != nil {
return err return err
} }
if req.Status != constant.AlertDisable { if req.Status != constant.AlertDisable {
if err := a.validateCronJobAlertChannels(req.Type, req.AdvancedParams, req.Method); err != nil {
return err
}
if err := a.validateAlertMethodEntitlement(methodTypes); err != nil { if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
return err return err
} }
@@ -278,6 +329,9 @@ func (a AlertService) UpdateStatus(id uint, status string) error {
return err return err
} }
if status == constant.AlertEnable { if status == constant.AlertEnable {
if err := a.validateCronJobAlertChannels(alertInfo.Type, alertInfo.AdvancedParams, alertInfo.Method); err != nil {
return err
}
if err := a.validateAlertMethodEntitlement(methodTypes); err != nil { if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
return err return err
} }
@@ -299,120 +353,12 @@ func (a AlertService) UpdateStatus(id uint, status string) error {
} }
func (a AlertService) GetDisks() ([]dto.DiskDTO, error) { func (a AlertService) GetDisks() ([]dto.DiskDTO, error) {
var disks []dto.DiskDTO infos := loadDiskInfo(true)
excludes := map[string]struct{}{ disks := make([]dto.DiskDTO, 0, len(infos))
"/mnt/cdrom": {}, "/boot": {}, "/boot/efi": {}, "/dev": {}, "/dev/shm": {}, for _, item := range infos {
"/run/lock": {}, "/run": {}, "/run/shm": {}, "/run/user": {}, disks = append(disks, dto.DiskDTO(item))
} }
stdout, err := executeDiskCommand()
if err != nil {
return disks, nil return disks, nil
}
lines := strings.Split(stdout, "\n")
var mounts []dto.AlertDiskInfo
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 7 {
continue
}
mountPoint := strings.Join(fields[6:], " ")
if shouldExclude(fields, mountPoint, excludes) {
continue
}
mounts = append(mounts, dto.AlertDiskInfo{Type: fields[1], Device: fields[0], Mount: mountPoint})
}
var (
wg sync.WaitGroup
mu sync.Mutex
)
wg.Add(len(mounts))
for i := 0; i < len(mounts); i++ {
go func(timeoutCh <-chan time.Time, mount dto.AlertDiskInfo) {
defer wg.Done()
var itemData dto.DiskDTO
itemData.Path = mount.Mount
itemData.Type = mount.Type
itemData.Device = mount.Device
select {
case <-timeoutCh:
mu.Lock()
disks = append(disks, itemData)
mu.Unlock()
global.LOG.Errorf("load disk info from %s failed, err: timeout", mount.Mount)
default:
state, err := disk.Usage(mount.Mount)
if err != nil {
mu.Lock()
disks = append(disks, itemData)
mu.Unlock()
global.LOG.Errorf("load disk info from %s failed, err: %v", mount.Mount, err)
return
}
itemData.Total = state.Total
itemData.Free = state.Free
itemData.Used = state.Used
itemData.UsedPercent = state.UsedPercent
itemData.InodesTotal = state.InodesTotal
itemData.InodesUsed = state.InodesUsed
itemData.InodesFree = state.InodesFree
itemData.InodesUsedPercent = state.InodesUsedPercent
mu.Lock()
disks = append(disks, itemData)
mu.Unlock()
}
}(time.After(5*time.Second), mounts[i])
}
wg.Wait()
sort.Slice(disks, func(i, j int) bool {
return disks[i].Path < disks[j].Path
})
return disks, nil
}
func executeDiskCommand() (string, error) {
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Second))
stdout, err := cmdMgr.RunWithStdout("df", "-hT", "-P")
if err != nil {
cmdMgr2 := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second))
stdout, err = cmdMgr2.RunWithStdout("df", "-lhT", "-P")
}
if err != nil {
return stdout, err
}
var lines []string
for _, line := range strings.Split(stdout, "\n") {
if !strings.Contains(line, "/") || strings.Contains(line, "tmpfs") || strings.Contains(line, "snap/core") || strings.Contains(line, "udev") {
continue
}
lines = append(lines, line)
}
if len(lines) == 0 {
return "", nil
}
return strings.Join(lines, "\n"), nil
}
func shouldExclude(fields []string, mountPoint string, excludes map[string]struct{}) bool {
if strings.HasPrefix(mountPoint, "/snap") || len(strings.Split(mountPoint, "/")) > 10 {
return true
}
if strings.TrimSpace(fields[1]) == "tmpfs" {
return true
}
if strings.Contains(fields[2], "K") {
return true
}
if strings.Contains(mountPoint, "docker") {
return true
}
_, excluded := excludes[mountPoint]
return excluded
} }
func (a AlertService) PageAlertLogs(search dto.AlertLogSearch) (int64, []dto.AlertLogDTO, error) { func (a AlertService) PageAlertLogs(search dto.AlertLogSearch) (int64, []dto.AlertLogDTO, error) {
@@ -677,25 +623,26 @@ func (a AlertService) updateCustomAlertConfig(req dto.AlertConfigUpdate, operato
}) })
} }
func (a AlertService) UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error { func (a AlertService) UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) (string, error) {
if err := validateAlertConfigStatus(req.Status); err != nil { if err := validateAlertConfigStatus(req.Status); err != nil {
return err return "", err
} }
config, err := alertRepo.GetConfigById(req.ID) config, err := alertRepo.GetConfigById(req.ID)
if err != nil { if err != nil {
return err return "", err
} }
name := alertConfigLogName(config.Config, config.Title)
if req.Status == constant.AlertEnable { if req.Status == constant.AlertEnable {
if err := a.validateCommunityAlertConfigType(config.Type); err != nil { if err := a.validateCommunityAlertConfigType(config.Type); err != nil {
return err return name, err
} }
if config.Type == constant.Custom { if config.Type == constant.Custom {
if _, err := alertwebhook.Resolve(config); err != nil { if _, err := alertwebhook.Resolve(config); err != nil {
return err return name, err
} }
} }
} }
return alertRepo.UpdateAlertConfig(map[string]interface{}{ return name, alertRepo.UpdateAlertConfig(map[string]interface{}{
"status": req.Status, "status": req.Status,
"update_user": operator, "update_user": operator,
}, repo.WithByID(req.ID)) }, repo.WithByID(req.ID))
@@ -867,19 +814,20 @@ func alertConfigSMSPhone(configData string) string {
return strings.TrimSpace(cfg.Phone) return strings.TrimSpace(cfg.Phone)
} }
func (a AlertService) DeleteAlertConfig(id uint) error { func (a AlertService) DeleteAlertConfig(id uint) (string, error) {
_, err := alertRepo.GetConfigById(id) config, err := alertRepo.GetConfigById(id)
if err != nil { if err != nil {
return err return "", err
} }
name := alertConfigLogName(config.Config, config.Title)
usedAlerts, err := alertRepo.List(alertRepo.WithByAlertMethodContainsConfigID(id)) usedAlerts, err := alertRepo.List(alertRepo.WithByAlertMethodContainsConfigID(id))
if err != nil { if err != nil {
return err return name, err
} }
if len(usedAlerts) > 0 { if len(usedAlerts) > 0 {
return buserr.New("ErrAlertConfigInUse") return name, buserr.New("ErrAlertConfigInUse")
} }
return alertRepo.DeleteAlertConfig(repo.WithByID(id)) return name, alertRepo.DeleteAlertConfig(repo.WithByID(id))
} }
func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) { func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
@@ -905,7 +853,7 @@ func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
msg := email.EmailMessage{ msg := email.EmailMessage{
Subject: i18n.GetMsgByKey("TestAlertTitle"), Subject: i18n.GetMsgByKey("TestAlertTitle"),
Body: i18n.GetMsgByKey("TestAlert"), Body: i18n.GetMsgByKey("TestAlertMessage"),
IsHTML: false, IsHTML: false,
} }
transport := xpack.MultiNodeProvider.LoadRequestTransport() transport := xpack.MultiNodeProvider.LoadRequestTransport()
@@ -953,7 +901,7 @@ func resolveEmailTestConfig(req dto.AlertConfigTest) (dto.AlertEmailConfig, erro
return emailConfig, nil return emailConfig, nil
} }
func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error) { func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest, lang string) (dto.AlertConfigTestResult, error) {
if req.Type != constant.Custom { if req.Type != constant.Custom {
return dto.AlertConfigTestResult{}, fmt.Errorf("alert config test type must be custom") return dto.AlertConfigTestResult{}, fmt.Errorf("alert config test type must be custom")
} }
@@ -982,12 +930,15 @@ func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertC
} }
tester, ok := xpack.AlertProvider.(providers.CustomWebhookTester) tester, ok := xpack.AlertProvider.(providers.CustomWebhookTester)
if !ok { if !ok {
if global.LOG != nil {
global.LOG.Warnf("webhook test failed: %v", providers.ErrCustomWebhookUnsupported)
}
return dto.AlertConfigTestResult{ return dto.AlertConfigTestResult{
Success: false, Success: false,
Message: providers.ErrCustomWebhookUnsupported.Error(), Message: providers.ErrCustomWebhookUnsupported.Error(),
}, nil }, nil
} }
return tester.TestCustomWebhook(resolved) return tester.TestCustomWebhook(resolved, lang)
} }
func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator string) error { func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator string) error {
@@ -1021,6 +972,23 @@ func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator
alertRepo.WithByType(updateAlert.Type), alertRepo.WithByType(updateAlert.Type),
alertRepo.WithByProject(updateAlert.Project), alertRepo.WithByProject(updateAlert.Project),
) )
advanced, err := prepareCronJobAlertParams(updateAlert.Type, alertInfo.AdvancedParams, updateAlert.AdvancedParams)
if err != nil {
return err
}
updateAlert.AdvancedParams = advanced
if alertUtil.GetCronJobType(updateAlert.Type) == "cronJob" {
upMap["advanced_params"] = advanced
}
if newStatus == constant.AlertEnable {
method := updateAlert.Method
if method == "" {
method = alertInfo.Method
}
if err := a.validateCronJobAlertChannels(updateAlert.Type, advanced, method); err != nil {
return err
}
}
if alertInfo.ID > 0 { if alertInfo.ID > 0 {
shouldUpdate := false shouldUpdate := false
@@ -1034,6 +1002,9 @@ func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator
if val, ok := upMap["method"]; ok && val != "" && val != alertInfo.Method { if val, ok := upMap["method"]; ok && val != "" && val != alertInfo.Method {
shouldUpdate = true shouldUpdate = true
} }
if val, ok := upMap["advanced_params"]; ok && val != alertInfo.AdvancedParams {
shouldUpdate = true
}
if shouldUpdate { if shouldUpdate {
if err := alertRepo.Update( if err := alertRepo.Update(
@@ -1055,3 +1026,32 @@ func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator
return nil return nil
} }
func prepareCronJobAlertParams(alertType, previous, incoming string) (string, error) {
if alertUtil.GetCronJobType(alertType) != "cronJob" {
return incoming, nil
}
return alertUtil.MergeCronJobAlertParams(previous, incoming)
}
func (a AlertService) validateCronJobAlertChannels(alertType, advanced, method string) error {
if alertUtil.GetCronJobType(alertType) != "cronJob" {
return nil
}
mode, err := alertUtil.CronJobAlertTriggerMode(advanced)
if err != nil || mode != alertUtil.CronJobAlertSuccess {
return err
}
_, err = a.validateAlertMethodReferences(method)
return err
}
func alertConfigLogName(config, title string) string {
var value struct {
DisplayName string `json:"displayName"`
}
if json.Unmarshal([]byte(config), &value) == nil && strings.TrimSpace(value.DisplayName) != "" {
return strings.TrimSpace(value.DisplayName)
}
return title
}
+31 -22
View File
@@ -34,6 +34,7 @@ const (
CheckIntervalSec = 3 CheckIntervalSec = 3
LoadCheckIntervalMin = 5 LoadCheckIntervalMin = 5
sshIPLoginWindow = 30 * time.Minute sshIPLoginWindow = 30 * time.Minute
sslAutoRenewAlertSkipDays = 31
) )
type AlertTaskHelper struct { type AlertTaskHelper struct {
@@ -529,11 +530,15 @@ func loadSSHLogin(alert dto.AlertDTO) {
if err != nil { if err != nil {
global.LOG.Errorf("Failed to load ssh login logs: %v", err) global.LOG.Errorf("Failed to load ssh login logs: %v", err)
} }
interfaceAddrs, err := net.InterfaceAddrs()
if err != nil {
global.LOG.Warnf("Failed to load local IP addresses for ssh login alert: %v", err)
}
count, records := summarizeSSHLoginHistories( count, records := summarizeSSHLoginHistories(
histories, histories,
now, now,
failedWindow, failedWindow,
strings.Split(strings.TrimSpace(alert.AdvancedParams), "\n"), sshSuccessLoginWhitelist(alert.AdvancedParams, interfaceAddrs),
) )
isAlert := count >= int(alert.Count) isAlert := count >= int(alert.Count)
if isAlert { if isAlert {
@@ -569,6 +574,19 @@ func loadSSHLogin(alert dto.AlertDTO) {
} }
} }
func sshSuccessLoginWhitelist(configured string, interfaceAddrs []net.Addr) []string {
whitelist := strings.Split(strings.TrimSpace(configured), "\n")
whitelist = append(whitelist, "127.0.0.0/8", "::1")
for _, addr := range interfaceAddrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP == nil || ipNet.IP.IsUnspecified() {
continue
}
whitelist = append(whitelist, ipNet.IP.String())
}
return whitelist
}
func filterLoginLogsNotInWhitelist(records []model.LoginLog, whitelist []string) []model.LoginLog { func filterLoginLogsNotInWhitelist(records []model.LoginLog, whitelist []string) []model.LoginLog {
filtered := make([]model.LoginLog, 0, len(records)) filtered := make([]model.LoginLog, 0, len(records))
for _, record := range records { for _, record := range records {
@@ -864,7 +882,7 @@ func calculateSSLExpiryDays(sslList []model.WebsiteSSL, cycle uint) (map[int][]s
daysDiff := int(math.Ceil( daysDiff := int(math.Ceil(
ssl.ExpireDate.Sub(currentDate).Hours() / 24, ssl.ExpireDate.Sub(currentDate).Hours() / 24,
)) ))
if daysDiff > 0 && int(cycle) >= daysDiff { if daysDiff > 0 && int(cycle) >= daysDiff && !shouldSuppressSSLExpiryAlert(ssl, daysDiff) {
daysDiffMap[daysDiff] = append(daysDiffMap[daysDiff], ssl.PrimaryDomain) daysDiffMap[daysDiff] = append(daysDiffMap[daysDiff], ssl.PrimaryDomain)
projectMap[ssl.ID] = append(projectMap[ssl.ID], ssl.ExpireDate) projectMap[ssl.ID] = append(projectMap[ssl.ID], ssl.ExpireDate)
} }
@@ -872,6 +890,10 @@ func calculateSSLExpiryDays(sslList []model.WebsiteSSL, cycle uint) (map[int][]s
return daysDiffMap, projectMap return daysDiffMap, projectMap
} }
func shouldSuppressSSLExpiryAlert(ssl model.WebsiteSSL, remainingDays int) bool {
return ssl.AutoRenew && remainingDays < sslAutoRenewAlertSkipDays
}
func calculateWebsiteExpiryDays(websites []model.Website, cycle uint) (map[int][]string, map[uint][]time.Time) { func calculateWebsiteExpiryDays(websites []model.Website, cycle uint) (map[int][]string, map[uint][]time.Time) {
currentDate := time.Now() currentDate := time.Now()
daysDiffMap := make(map[int][]string) daysDiffMap := make(map[int][]string)
@@ -1044,50 +1066,37 @@ func processAllDisks(alert dto.AlertDTO) error {
global.LOG.Errorf("error getting disk list, err: %v", err) global.LOG.Errorf("error getting disk list, err: %v", err)
return err return err
} }
var errMsgs []string
for _, item := range diskList { for _, item := range diskList {
err := checkAndCreateDiskAlert(alert, item.Path) if item.Total == 0 {
if err != nil {
errMsg := fmt.Sprintf("disk path %s process failed: %v", item.Path, err)
errMsgs = append(errMsgs, errMsg)
global.LOG.Errorf("%s", errMsg)
continue continue
} }
} checkAndCreateDiskAlert(alert, item.Path, &disk.UsageStat{Used: item.Used, UsedPercent: item.UsedPercent})
if len(errMsgs) > 0 {
return fmt.Errorf("batch process disks failed, error count: %d, details: %s", len(errMsgs), strings.Join(errMsgs, "; "))
} }
return nil return nil
} }
func processSingleDisk(alert dto.AlertDTO) error { func processSingleDisk(alert dto.AlertDTO) error {
err := checkAndCreateDiskAlert(alert, alert.Project) usageStat, err := loadDiskUsageWithTimeout(alert.Project, true)
if err != nil { if err != nil {
global.LOG.Errorf("%s", err.Error()) global.LOG.Errorf("error getting disk usage for %s, err: %v", alert.Project, err)
return err return err
} }
checkAndCreateDiskAlert(alert, alert.Project, usageStat)
return nil return nil
} }
func checkAndCreateDiskAlert(alert dto.AlertDTO, path string) error { func checkAndCreateDiskAlert(alert dto.AlertDTO, path string, usageStat *disk.UsageStat) {
usageStat, err := psutil.DISK.GetUsage(path, false)
if err != nil {
global.LOG.Errorf("error getting disk usage for %s, err: %v", path, err)
return err
}
usedTotal, usedStr := calculateUsedTotal(alert.Cycle, usageStat) usedTotal, usedStr := calculateUsedTotal(alert.Cycle, usageStat)
commonTotal := float64(alert.Count) commonTotal := float64(alert.Count)
if alert.Cycle == 1 { if alert.Cycle == 1 {
commonTotal *= 1024 * 1024 * 1024 commonTotal *= 1024 * 1024 * 1024
} }
if usedTotal < commonTotal { if usedTotal < commonTotal {
return nil return
} }
params := createAlertDiskParams(path, usedStr) params := createAlertDiskParams(path, usedStr)
sender := NewAlertSender(alert, alert.Project) sender := NewAlertSender(alert, alert.Project)
sender.ResourceSend(path, params) sender.ResourceSend(path, params)
return nil
} }
func calculateUsedTotal(cycle uint, usageStat *disk.UsageStat) (float64, string) { func calculateUsedTotal(cycle uint, usageStat *disk.UsageStat) (float64, string) {
+49 -3
View File
@@ -258,13 +258,22 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version) versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
if !fileOp.Stat(versionPath) || detail.Update {
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) { if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
return appDetailDTO, err return appDetailDTO, err
} }
if err == nil && app.Resource == constant.AppResourceRemote {
detail.Update = false
appDetailDTO.Update = false
} }
switch app.Type { switch app.Type {
case constant.RuntimePHP: case constant.RuntimePHP:
paramMap := make(map[string]interface{})
if app.Resource == constant.AppResourceRemote {
if err = json.Unmarshal([]byte(detail.Params), &paramMap); err != nil {
return appDetailDTO, err
}
appDetailDTO.Params = paramMap
} else {
paramsPath := filepath.Join(versionPath, "data.yml") paramsPath := filepath.Join(versionPath, "data.yml")
if !fileOp.Stat(paramsPath) { if !fileOp.Stat(paramsPath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil) return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil)
@@ -273,11 +282,11 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err != nil { if err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
paramMap := make(map[string]interface{})
if err = yaml.Unmarshal(param, &paramMap); err != nil { if err = yaml.Unmarshal(param, &paramMap); err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
appDetailDTO.Params = paramMap["additionalProperties"] appDetailDTO.Params = paramMap["additionalProperties"]
}
composePath := filepath.Join(versionPath, "docker-compose.yml") composePath := filepath.Join(versionPath, "docker-compose.yml")
if !fileOp.Stat(composePath) { if !fileOp.Stat(composePath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil) return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil)
@@ -308,6 +317,12 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if appDetailDTO.DockerCompose == "" { if appDetailDTO.DockerCompose == "" {
filename := filepath.Base(appDetailDTO.DownloadUrl) filename := filepath.Base(appDetailDTO.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml") dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return appDetailDTO, err
}
}
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
return appDetailDTO, buserr.New("ErrAppVersionUnavailable") return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
@@ -434,6 +449,12 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
} else { } else {
if appDetail.DockerCompose == "" { if appDetail.DockerCompose == "" {
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version) dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return
}
}
var statusCode int var statusCode int
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
@@ -478,6 +499,22 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
req.Params[constant.ContainerName] = containerName req.Params[constant.ContainerName] = containerName
appInstall.ContainerName = containerName appInstall.ContainerName = containerName
modelAccountBaseURL := ""
if app.Key == "ollama" {
appInstall.SyncModelAccount = req.SyncModelAccount == nil || *req.SyncModelAccount
appInstall.ModelAccountBaseURLType = req.ModelAccountBaseURLType
if appInstall.ModelAccountBaseURLType == "" {
appInstall.ModelAccountBaseURLType = "container"
}
appInstall.ModelAccountBaseURL = req.ModelAccountBaseURL
}
if app.Key == "ollama" && appInstall.SyncModelAccount {
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, containerName, "11434", httpPort)
if err != nil {
return
}
}
index := 0 index := 0
serviceName := "" serviceName := ""
for k := range servicesMap { for k := range servicesMap {
@@ -550,6 +587,11 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
installTask, err := task.NewTaskWithOps(appInstall.Name, task.TaskInstall, task.TaskScopeApp, req.TaskID, appInstall.ID) installTask, err := task.NewTaskWithOps(appInstall.Name, task.TaskInstall, task.TaskScopeApp, req.TaskID, appInstall.ID)
if err != nil { if err != nil {
appInstall.Status = constant.StatusInstallErr
appInstall.Message = err.Error()
if saveErr := appInstallRepo.Save(context.Background(), appInstall); saveErr != nil {
err = fmt.Errorf("%w; save failed install status: %v", err, saveErr)
}
return return
} }
@@ -557,7 +599,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return return
} }
installApp := func(t *task.Task) error { installApp := func(t *task.Task) (err error) {
if err = copyData(t, app, appDetail, appInstall, req); err != nil { if err = copyData(t, app, appDetail, appInstall, req); err != nil {
return err return err
} }
@@ -583,6 +625,10 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err return err
} }
updateToolApp(appInstall) updateToolApp(appInstall)
if app.Key == "ollama" && appInstall.SyncModelAccount {
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
return err
}
return nil return nil
} }
+98 -7
View File
@@ -159,6 +159,14 @@ func (a *AppInstallService) CheckExist(req request.AppInstalledInfo) (*response.
} }
res.ContainerName = appInstall.ContainerName res.ContainerName = appInstall.ContainerName
res.ModelAccountBaseURLType = appInstall.ModelAccountBaseURLType
res.ModelAccountBaseURL = appInstall.ModelAccountBaseURL
if app.Key == "ollama" {
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(appInstall)
if err != nil {
return nil, err
}
}
res.Name = appInstall.Name res.Name = appInstall.Name
res.Version = appInstall.Version res.Version = appInstall.Version
res.CreatedAt = appInstall.CreatedAt res.CreatedAt = appInstall.CreatedAt
@@ -339,6 +347,11 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID) operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil { if err != nil {
install.Status = constant.StatusUpErr
install.Message = err.Error()
if saveErr := appInstallRepo.Save(context.Background(), &install); saveErr != nil {
return fmt.Errorf("%w; save failed operation status: %v", err, saveErr)
}
return err return err
} }
operation := string(req.Operate) operation := string(req.Operate)
@@ -433,6 +446,32 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
} }
} }
modelAccountBaseURL := ""
if installed.App.Key == "ollama" {
if req.SyncModelAccount != nil {
installed.SyncModelAccount = *req.SyncModelAccount
}
if req.ModelAccountBaseURLType != "" {
installed.ModelAccountBaseURLType = req.ModelAccountBaseURLType
installed.ModelAccountBaseURL = req.ModelAccountBaseURL
} else if installed.SyncModelAccount {
installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(oldInstalled)
if err != nil {
return err
}
}
if installed.SyncModelAccount {
containerName := installed.ContainerName
if req.Advanced && req.ContainerName != "" {
containerName = req.ContainerName
}
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, containerName, "11434", installed.HttpPort)
if err != nil {
return err
}
}
}
backupDockerCompose := installed.DockerCompose backupDockerCompose := installed.DockerCompose
if req.Advanced { if req.Advanced {
composeMap := make(map[string]interface{}) composeMap := make(map[string]interface{})
@@ -558,6 +597,9 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
if proxyChanged && website.ID != 0 && currentProxyErr != nil { if proxyChanged && website.ID != 0 && currentProxyErr != nil {
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error()) global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error())
} }
if installed.App.Key == "ollama" && installed.SyncModelAccount {
return syncOllamaModelAccount(installed, modelAccountBaseURL, true, true)
}
return nil return nil
} }
@@ -567,11 +609,15 @@ func (a *AppInstallService) SyncAll(systemInit bool) error {
return err return err
} }
for _, i := range allList { for _, i := range allList {
if i.Status == constant.StatusInstalling || i.Status == constant.StatusUpgrading || i.Status == constant.StatusRebuilding || i.Status == constant.StatusUninstalling { if appInstallOperationInterruptedOnRestart(i.Status) {
if systemInit { if systemInit {
i.Status = constant.StatusError i.Status = appInstallOperationFailureStatus(i.Status)
i.Message = "1Panel restart causes the task to terminate" i.Message = constant.InterruptedMsg
_ = appInstallRepo.Save(context.Background(), &i) if err := appInstallRepo.Save(context.Background(), &i); err != nil {
return err
}
} else if err := syncAppInstallStatus(&i, false); err != nil {
global.LOG.Errorf("sync install app[%s] error,mgs: %s", i.Name, err.Error())
} }
continue continue
} }
@@ -678,14 +724,17 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
if !canAccessVllmVersion(app.Key, detail.Version) { if !canAccessVllmVersion(app.Key, detail.Version) {
continue continue
} }
if !isAIGatewayUpgradeVersionAllowed(app.Key, install.Version, detail.Version) {
continue
}
ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(detail.ID), appIgnoreUpgradeRepo.WithScope("version")) ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(detail.ID), appIgnoreUpgradeRepo.WithScope("version"))
if len(ignores) > 0 { if len(ignores) > 0 {
continue continue
} }
if common.IsCrossVersion(install.Version, detail.Version) && !app.CrossVersionUpdate { if isCrossAppVersion(app.Key, install.Version, detail.Version) && !app.CrossVersionUpdate {
continue continue
} }
canUpgrade := common.CompareVersion(detail.Version, install.Version) canUpgrade := isNewerAppVersion(app.Key, detail.Version, install.Version)
if app.Key == vllmAppKeyForUpgrade { if app.Key == vllmAppKeyForUpgrade {
currentImage := loadVllmImageFromEnv(install.Env) currentImage := loadVllmImageFromEnv(install.Env)
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage) canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
@@ -695,6 +744,12 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() { if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
filename := filepath.Base(detail.DownloadUrl) filename := filepath.Base(detail.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml") dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return versions, err
}
}
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
return versions, buserr.New("ErrAppVersionUnavailable") return versions, buserr.New("ErrAppVersionUnavailable")
@@ -726,7 +781,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
} }
} }
sort.Slice(versions, func(i, j int) bool { sort.Slice(versions, func(i, j int) bool {
return common.CompareVersion(versions[i].Version, versions[j].Version) return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version)
}) })
return versions, nil return versions, nil
} }
@@ -916,6 +971,18 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
config := getAppCommonConfig(envs) config := getAppCommonConfig(envs)
config.DockerCompose = install.DockerCompose config.DockerCompose = install.DockerCompose
res.Params = params res.Params = params
res.AppKey = install.App.Key
res.Name = install.Name
res.HttpPort = install.HttpPort
res.SyncModelAccount = install.SyncModelAccount
res.ModelAccountBaseURLType = install.ModelAccountBaseURLType
res.ModelAccountBaseURL = install.ModelAccountBaseURL
if install.App.Key == "ollama" {
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(install)
if err != nil {
return nil, err
}
}
if config.ContainerName == "" { if config.ContainerName == "" {
config.ContainerName = install.ContainerName config.ContainerName = install.ContainerName
} }
@@ -931,10 +998,34 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
} }
func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error { func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
operation := ""
switch appInstall.Status {
case constant.StatusInstalling:
operation = task.TaskInstall
case constant.StatusUpgrading:
operation = task.TaskUpgrade
case constant.StatusRebuilding:
operation = task.TaskBuild
case constant.StatusUninstalling:
operation = task.TaskUninstall
case constant.StatusStarting, constant.StatusWaiting:
operation = task.TaskUpdate
case constant.StatusRestarting:
operation = task.TaskRestart
}
if operation != "" {
if err := reconcileAppInstallTaskFailure(appInstall, operation, global.DB, global.TaskDB); err != nil {
return err
}
}
switch appInstall.Status { switch appInstall.Status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling, case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting: constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
return nil return nil
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
if !force {
return nil
}
} }
cli, err := docker.NewClient() cli, err := docker.NewClient()
if err != nil { if err != nil {
+53
View File
@@ -0,0 +1,53 @@
package service
import (
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error {
changed := errors.New("MCP website directory changed; inspect the installation before retrying")
directory, ok := req.Params["WEBSITE_DIR"].(string)
if !ok || directory == "" || global.DB == nil {
return changed
}
return global.DB.Transaction(func(tx *gorm.DB) error {
var setting model.Setting
err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil {
return err
}
} else if setting.Value == "" {
result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return changed
}
}
if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil {
return err
}
return nil
})
}
+76
View File
@@ -0,0 +1,76 @@
package service
import (
"errors"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm"
)
func appInstallOperationFailureStatus(status string) string {
switch status {
case constant.StatusInstalling:
return constant.StatusInstallErr
case constant.StatusUpgrading:
return constant.StatusUpgradeErr
case constant.StatusUninstalling:
return constant.StatusError
default:
return constant.StatusUpErr
}
}
func appInstallOperationPending(status string) bool {
switch status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
return true
default:
return false
}
}
func appInstallOperationInterruptedOnRestart(status string) bool {
// ReStarting can also describe a container's Docker restart policy.
return appInstallOperationPending(status) && status != constant.StatusRestarting
}
func reconcileAppInstallTaskFailure(install *model.AppInstall, operation string, appDB, taskDB *gorm.DB) error {
if !appInstallOperationPending(install.Status) {
return nil
}
var operationTask model.Task
err := taskDB.Where("type = ? AND resource_id = ? AND operate = ?", "App", install.ID, operation).
Order("created_at DESC").First(&operationTask).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if operationTask.Status != constant.StatusFailed && operationTask.Status != constant.StatusCanceled {
return nil
}
// A previous operation must not turn a newly queued retry into a failure.
if operationTask.CreatedAt.Before(install.UpdatedAt) &&
(operationTask.EndAt.IsZero() || operationTask.EndAt.Before(install.UpdatedAt)) {
return nil
}
message := operationTask.ErrorMsg
if message == "" {
message = "the application operation task failed"
}
status := appInstallOperationFailureStatus(install.Status)
result := appDB.Model(&model.AppInstall{}).
Where("id = ? AND status = ? AND updated_at = ?", install.ID, install.Status, install.UpdatedAt).
Updates(map[string]interface{}{"status": status, "message": message})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
install.Status = status
install.Message = message
}
return nil
}
@@ -0,0 +1,147 @@
package service
import (
"path/filepath"
"testing"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
func operationStatusTestDB(t *testing.T) (*gorm.DB, *gorm.DB, model.AppInstall) {
t.Helper()
open := func(name string, schema interface{}) *gorm.DB {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
return db
}
appDB := open("apps.db", &model.AppInstall{})
taskDB := open("tasks.db", &model.Task{})
install := model.AppInstall{
Name: "gb10-ds-vision", AppId: 1, AppDetailId: 1,
Version: "nvidia-gb10-dspark-0.1.1", ContainerName: "vllm-dspark-1", ServiceName: "vllm-dspark",
Status: constant.StatusStarting, Env: `{"MODEL_DIR":"/models/vision"}`,
}
if err := appDB.Omit(clause.Associations).Create(&install).Error; err != nil {
t.Fatal(err)
}
return appDB, taskDB, install
}
func TestAppInstallFailedTaskRecoversPendingState(t *testing.T) {
for _, state := range []string{constant.StatusStarting, constant.StatusWaiting, constant.StatusRestarting, constant.StatusInstalling} {
t.Run(state, func(t *testing.T) {
appDB, taskDB, install := operationStatusTestDB(t)
install.Status = state
if err := appDB.Omit(clause.Associations).Save(&install).Error; err != nil {
t.Fatal(err)
}
task := model.Task{ID: "failed", Type: "App", Operate: "TaskUpdate", ResourceID: install.ID,
Status: constant.StatusFailed, ErrorMsg: "model startup script failed", CreatedAt: install.UpdatedAt.Add(time.Second), EndAt: install.UpdatedAt.Add(2 * time.Second)}
if err := taskDB.Create(&task).Error; err != nil {
t.Fatal(err)
}
if err := reconcileAppInstallTaskFailure(&install, task.Operate, appDB, taskDB); err != nil {
t.Fatal(err)
}
want := constant.StatusUpErr
if state == constant.StatusInstalling {
want = constant.StatusInstallErr
}
var stored model.AppInstall
if err := appDB.First(&stored, install.ID).Error; err != nil {
t.Fatal(err)
}
if install.Status != want || stored.Status != want || stored.Message != task.ErrorMsg || stored.Env != install.Env {
t.Fatalf("recovered state = %s/%s, message %q, env %q", install.Status, stored.Status, stored.Message, stored.Env)
}
})
}
}
func TestAppInstallPendingStateKeepsActiveAndUnrelatedTasks(t *testing.T) {
for _, taskStatus := range []string{constant.StatusExecuting, constant.StatusSuccess, "missing", "previous-failure", "other-resource"} {
t.Run(taskStatus, func(t *testing.T) {
appDB, taskDB, install := operationStatusTestDB(t)
task := model.Task{ID: "task", Type: "App", Operate: "TaskUpdate", ResourceID: install.ID,
Status: taskStatus, CreatedAt: install.UpdatedAt.Add(time.Second), EndAt: install.UpdatedAt.Add(2 * time.Second)}
if taskStatus == "previous-failure" {
task.Status = constant.StatusFailed
task.CreatedAt = install.UpdatedAt.Add(-2 * time.Hour)
task.EndAt = install.UpdatedAt.Add(-time.Hour)
}
if taskStatus == "other-resource" {
task.Status = constant.StatusFailed
task.ResourceID++
}
if taskStatus != "missing" {
if err := taskDB.Create(&task).Error; err != nil {
t.Fatal(err)
}
}
if err := reconcileAppInstallTaskFailure(&install, task.Operate, appDB, taskDB); err != nil {
t.Fatal(err)
}
if install.Status != constant.StatusStarting {
t.Fatalf("unrelated or active task changed pending state to %s", install.Status)
}
})
}
}
func TestAppInstallRecoveryDoesNotOverwriteConcurrentRetry(t *testing.T) {
appDB, taskDB, install := operationStatusTestDB(t)
task := model.Task{ID: "failed", Type: "App", Operate: "TaskUpdate", ResourceID: install.ID,
Status: constant.StatusCanceled, ErrorMsg: "interrupted", CreatedAt: install.UpdatedAt.Add(time.Second), EndAt: install.UpdatedAt.Add(2 * time.Second)}
if err := taskDB.Create(&task).Error; err != nil {
t.Fatal(err)
}
if err := appDB.Callback().Update().Before("gorm:update").Register("new-operation", func(db *gorm.DB) {
if err := db.Exec("UPDATE app_installs SET updated_at = ?, message = ? WHERE id = ?", install.UpdatedAt.Add(time.Hour), "new attempt", install.ID).Error; err != nil {
t.Error(err)
}
}); err != nil {
t.Fatal(err)
}
if err := reconcileAppInstallTaskFailure(&install, task.Operate, appDB, taskDB); err != nil {
t.Fatal(err)
}
var stored model.AppInstall
if err := appDB.First(&stored, install.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Status != constant.StatusStarting || stored.Message != "new attempt" {
t.Fatalf("recovery overwrote a newer operation: %s, %s", stored.Status, stored.Message)
}
}
func TestAppInstallRestartRecoveryCoversLifecycleStates(t *testing.T) {
for _, state := range []string{constant.StatusStarting, constant.StatusWaiting, constant.StatusInstalling} {
if !appInstallOperationInterruptedOnRestart(state) {
t.Errorf("restart recovery omitted %s", state)
}
}
for _, state := range []string{constant.StatusRunning, constant.StatusStopped, constant.StatusRestarting} {
if appInstallOperationInterruptedOnRestart(state) {
t.Errorf("restart recovery changes a Docker runtime state: %s", state)
}
}
}
+5 -1
View File
@@ -191,7 +191,11 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
if _, ok := InitTypes[app.Type]; ok { if _, ok := InitTypes[app.Type]; ok {
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml") dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
_, composeRes, err := req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s) dockerComposeUrl, err := appDownloadURL(dockerComposeUrl, strconv.Itoa(c.list.LastModified))
var composeRes []byte
if err == nil {
_, composeRes, err = req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
}
if err == nil { if err == nil {
detail.DockerCompose = string(composeRes) detail.DockerCompose = string(composeRes)
} else { } else {
+32 -57
View File
@@ -40,14 +40,13 @@ const (
appUpgradeDown appUpgradeDown
appUpgradeMutated appUpgradeMutated
appUpgradeStarted appUpgradeStarted
appUpgradeReady
appUpgradeCommitted appUpgradeCommitted
) )
const composeServiceLabel = "com.docker.compose.service"
var appUpgradeLocks sync.Map var appUpgradeLocks sync.Map
const composeServiceLabel = "com.docker.compose.service"
type appUpgradeSnapshot interface { type appUpgradeSnapshot interface {
Restore() error Restore() error
Cleanup() Cleanup()
@@ -107,6 +106,14 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
if err != nil { if err != nil {
return err return err
} }
if !isAIGatewayUpgradeVersionAllowed(install.App.Key, install.Version, detail.Version) {
return buserr.New("ErrAIGatewayDatabaseTypeMismatch")
}
if isHermesApp(install.App.Key) &&
(!isNewerAppVersion(install.App.Key, detail.Version, install.Version) ||
(isCrossAppVersion(install.App.Key, install.Version, detail.Version) && !install.App.CrossVersionUpdate)) {
return buserr.New("ErrHermesUpgradeVersion")
}
if err = checkVllmVersionAccess(install.App.Key, detail.Version); err != nil { if err = checkVllmVersionAccess(install.App.Key, detail.Version); err != nil {
return err return err
} }
@@ -439,15 +446,14 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
t.LogSuccess(logStr) t.LogSuccess(logStr)
u.phase = appUpgradeStarted u.phase = appUpgradeStarted
t.LogStart(i18n.GetMsgByKey("UpgradeWaitReady")) containerNames, discoverErr := discoverUpgradeContainerNames(u.candidate, u.envContent)
containerNames, err := waitAppContainersReady(context.Background(), u.candidate) if discoverErr != nil {
if err != nil { t.Logf("WARNING: discover upgraded application containers failed: %v", discoverErr)
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeWaitReady"), err) } else if len(containerNames) > 0 {
return err
}
t.LogSuccess(i18n.GetMsgByKey("UpgradeWaitReady"))
u.phase = appUpgradeReady
u.candidate.ContainerName = strings.Join(containerNames, ",") u.candidate.ContainerName = strings.Join(containerNames, ",")
} else {
t.Log("WARNING: no containers found for the upgraded application")
}
u.candidate.Status = constant.StatusRunning u.candidate.Status = constant.StatusRunning
u.candidate.Message = "" u.candidate.Message = ""
@@ -472,6 +478,11 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
} else if err = appInstallRepo.Save(context.Background(), &u.candidate); err != nil { } else if err = appInstallRepo.Save(context.Background(), &u.candidate); err != nil {
return err return err
} }
if discoverErr == nil && len(containerNames) > 0 {
if syncErr := syncAppInstallStatus(&u.candidate, true); syncErr != nil {
t.Logf("WARNING: sync upgraded application status failed: %v", syncErr)
}
}
u.phase = appUpgradeCommitted u.phase = appUpgradeCommitted
u.deleteOldImages(t) u.deleteOldImages(t)
return nil return nil
@@ -591,9 +602,6 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
} }
func (u *appUpgradeContext) finishRollback() error { func (u *appUpgradeContext) finishRollback() error {
if _, err := waitAppContainersReady(context.Background(), u.original); err != nil {
return err
}
restored := u.original restored := u.original
if err := appInstallRepo.Save(context.Background(), &restored); err != nil { if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
return err return err
@@ -881,28 +889,7 @@ func (s *upgradeFileSnapshot) Cleanup() {
} }
} }
type appContainerReadinessClient interface { func discoverUpgradeContainerNames(install model.AppInstall, envContent []byte) ([]string, error) {
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
}
func waitAppContainersReady(ctx context.Context, install model.AppInstall) ([]string, error) {
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
return waitAppContainersReadyWithClient(ctx, client, install)
}
func waitAppContainersReadyWithClient(ctx context.Context, client appContainerReadinessClient, install model.AppInstall) ([]string, error) {
envContent, err := os.ReadFile(install.GetEnvPath())
if err != nil {
envContent, err = renderUpgradeEnv(&install, nil)
if err != nil {
return nil, err
}
}
project, err := docker.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), envContent, false) project, err := docker.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), envContent, false)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -916,36 +903,24 @@ func waitAppContainersReadyWithClient(ctx context.Context, client appContainerRe
if len(expectedServices) == 0 { if len(expectedServices) == 0 {
return strings.Split(install.ContainerName, ","), nil return strings.Split(install.ContainerName, ","), nil
} }
options := container.ListOptions{ client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
containers, err := client.ContainerList(context.Background(), container.ListOptions{
All: true, All: true,
Filters: filters.NewArgs( Filters: filters.NewArgs(filters.Arg("label", composeWorkdirLabel+"="+install.GetPath())),
filters.Arg("label", composeWorkdirLabel+"="+install.GetPath()), })
),
}
containers, err := client.ContainerList(ctx, options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
foundServices := make(map[string]bool, len(expectedServices))
containerNames := make([]string, 0, len(containers)) containerNames := make([]string, 0, len(containers))
for _, item := range containers { for _, item := range containers {
serviceName := item.Labels[composeServiceLabel] if _, ok := expectedServices[item.Labels[composeServiceLabel]]; ok && len(item.Names) > 0 {
if _, ok := expectedServices[serviceName]; !ok {
continue
}
if err = waitContainerReady(ctx, client, item.ID); err != nil {
return nil, fmt.Errorf("container %s is not ready: %w", serviceName, err)
}
foundServices[serviceName] = true
if len(item.Names) > 0 {
containerNames = append(containerNames, strings.TrimPrefix(item.Names[0], "/")) containerNames = append(containerNames, strings.TrimPrefix(item.Names[0], "/"))
} }
} }
for serviceName := range expectedServices {
if !foundServices[serviceName] {
return nil, fmt.Errorf("container for service %s was not created", serviceName)
}
}
sort.Strings(containerNames) sort.Strings(containerNames)
return containerNames, nil return containerNames, nil
} }
+109 -30
View File
@@ -8,6 +8,7 @@ import (
"maps" "maps"
"math" "math"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -352,22 +353,34 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if dir != nil { if dir != nil {
logStr := i18n.GetMsgByKey("Stop") + i18n.GetMsgByKey("App") logStr := i18n.GetMsgByKey("Stop") + i18n.GetMsgByKey("App")
t.Log(logStr) t.Log(logStr)
cleanupFailed := false
if deleteReq.UseLifecycleScripts { if deleteReq.UseLifecycleScripts {
if err = runScript(t, &install, "uninstall"); err != nil { if scriptErr := runScript(t, &install, "uninstall"); scriptErr != nil {
return err cleanupFailed = true
if !deleteReq.ForceDelete {
return scriptErr
}
} }
} else { } else {
out, err := compose.Down(install.GetComposePath()) out, downErr := compose.Down(install.GetComposePath())
if err != nil && !deleteReq.ForceDelete { if downErr != nil {
return handleErr(install, err, out) cleanupFailed = true
if !deleteReq.ForceDelete {
return handleErr(install, downErr, out)
} }
if err = runScript(t, &install, "uninstall"); err != nil { }
if scriptErr := runScript(t, &install, "uninstall"); scriptErr != nil {
cleanupFailed = true
if !deleteReq.ForceDelete {
_, _ = compose.Up(install.GetComposePath()) _, _ = compose.Up(install.GetComposePath())
return err return scriptErr
} }
} }
}
if !cleanupFailed {
t.LogSuccess(logStr) t.LogSuccess(logStr)
}
if deleteReq.DeleteImage { if deleteReq.DeleteImage {
content, err := op.GetContent(install.GetEnvPath()) content, err := op.GetContent(install.GetEnvPath())
if err != nil { if err != nil {
@@ -378,12 +391,27 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
} }
} }
} }
if install.App.Key == "ollama" {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
}
tx, ctx := helper.GetTxAndContext() tx, ctx := helper.GetTxAndContext()
defer tx.Rollback() defer tx.Rollback()
if err = appInstallRepo.Delete(ctx, install); err != nil { if err = appInstallRepo.Delete(ctx, install); err != nil {
return err return err
} }
appKey := install.App.Key appKey := install.App.Key
if appKey == "ollama" {
remaining, listErr := appInstallRepo.ListBy(ctx, appInstallRepo.WithAppId(install.AppId))
if listErr != nil {
return listErr
}
if len(remaining) == 0 {
if err = aiRepo.DeleteAll(ctx); err != nil {
return err
}
}
}
if isAgentAppKey(appKey) { if isAgentAppKey(appKey) {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID) _ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
} }
@@ -456,7 +484,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err == nil && len(entries) == 0 { if err == nil && len(entries) == 0 {
_ = op.DeleteDir(parentDir) _ = op.DeleteDir(parentDir)
} }
tx.Commit() if err := tx.Commit().Error; err != nil {
return err
}
if install.App.Key == "ollama" || install.App.Key == "vllm" {
CleanupLocalModelAccount(install.ID)
}
existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId)) existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
if len(existApps) == 0 { if len(existApps) == 0 {
@@ -466,8 +499,9 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
} }
uninstallTask.AddSubTask(task.GetTaskName(install.Name, task.TaskUninstall, task.TaskScopeApp), uninstall, nil) uninstallTask.AddSubTask(task.GetTaskName(install.Name, task.TaskUninstall, task.TaskScopeApp), uninstall, nil)
go func() { go func() {
if err := uninstallTask.Execute(); err != nil && !deleteReq.ForceDelete { if err := uninstallTask.Execute(); err != nil {
install.Status = constant.StatusError install.Status = constant.StatusError
install.Message = err.Error()
_ = appInstallRepo.Save(context.Background(), &install) _ = appInstallRepo.Save(context.Background(), &install)
} }
}() }()
@@ -878,16 +912,52 @@ func handleMap(params map[string]interface{}, envParams map[string]string) {
} }
} }
func appDownloadURL(rawURL, version string) (string, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", err
}
query := parsedURL.Query()
query.Set("v", version)
parsedURL.RawQuery = query.Encode()
return parsedURL.String(), nil
}
func currentAppDownloadURL(rawURL string) (string, error) {
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
if err != nil {
return "", err
}
return appDownloadURL(rawURL, storeVersion)
}
func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, logger *logrus.Logger) (err error) { func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, logger *logrus.Logger) (err error) {
if app.IsLocalApp() || app.IsCustomApp() { if app.IsLocalApp() || app.IsCustomApp() {
return nil return nil
} }
defer func() {
if err != nil && appInstall != nil {
appInstall.Status = constant.StatusDownloadErr
appInstall.Message = err.Error()
}
}()
appResourceDir := path.Join(global.Dir.AppResourceDir, app.Resource) appResourceDir := path.Join(global.Dir.AppResourceDir, app.Resource)
appDownloadDir := app.GetAppResourcePath() appDownloadDir := app.GetAppResourcePath()
appVersionDir := path.Join(appDownloadDir, appDetail.Version) appVersionDir := path.Join(appDownloadDir, appDetail.Version)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
if err != nil {
return err
}
cacheVersionPath := path.Join(appVersionDir, ".1panel-appstore-version")
if !appDetail.Update && fileOp.Stat(appVersionDir) { if !appDetail.Update && fileOp.Stat(appVersionDir) {
return if cachedVersion, readErr := os.ReadFile(cacheVersionPath); readErr == nil && string(cachedVersion) == storeVersion {
return nil
}
}
downloadURL, err := appDownloadURL(appDetail.DownloadUrl, storeVersion)
if err != nil {
return err
} }
if !fileOp.Stat(appDownloadDir) { if !fileOp.Stat(appDownloadDir) {
_ = fileOp.CreateDir(appDownloadDir, constant.DirPerm) _ = fileOp.CreateDir(appDownloadDir, constant.DirPerm)
@@ -896,23 +966,14 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
_ = fileOp.CreateDir(appVersionDir, constant.DirPerm) _ = fileOp.CreateDir(appVersionDir, constant.DirPerm)
} }
if logger == nil { if logger == nil {
global.LOG.Infof("download app [%s] from %s", app.Name, appDetail.DownloadUrl) global.LOG.Infof("download app [%s] from %s", app.Name, downloadURL)
} else { } else {
logger.Printf("download app [%s] from %s", app.Name, appDetail.DownloadUrl) logger.Printf("download app [%s] from %s", app.Name, downloadURL)
} }
filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz") filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz")
defer func() { if err = files.DownloadFileWithProxy(downloadURL, filePath); err != nil {
if err != nil {
if appInstall != nil {
appInstall.Status = constant.StatusDownloadErr
appInstall.Message = err.Error()
}
}
}()
if err = files.DownloadFileWithProxy(appDetail.DownloadUrl, filePath); err != nil {
if logger == nil { if logger == nil {
global.LOG.Errorf("download app [%s] error %v", app.Name, err) global.LOG.Errorf("download app [%s] error %v", app.Name, err)
} else { } else {
@@ -929,6 +990,9 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
return return
} }
_ = fileOp.DeleteFile(filePath) _ = fileOp.DeleteFile(filePath)
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
return err
}
appDetail.Update = false appDetail.Update = false
_ = appDetailRepo.Update(context.Background(), appDetail) _ = appDetailRepo.Update(context.Background(), appDetail)
return return
@@ -1393,9 +1457,8 @@ func handleErr(install model.AppInstall, err error, out string) error {
} }
func doNotNeedSync(installed model.AppInstall) bool { func doNotNeedSync(installed model.AppInstall) bool {
return installed.Status == constant.StatusInstalling || installed.Status == constant.StatusRebuilding || installed.Status == constant.StatusUpgrading || return appInstallOperationPending(installed.Status) || installed.Status == constant.StatusSyncing ||
installed.Status == constant.StatusSyncing || installed.Status == constant.StatusUninstalling || installed.Status == constant.StatusInstallErr || installed.Status == constant.StatusInstallErr || installed.Status == constant.StatusUpgradeErr || installed.Status == constant.StatusUpErr
installed.Status == constant.StatusStarting || installed.Status == constant.StatusRestarting || installed.Status == constant.StatusWaiting
} }
func synAppInstall(containers map[string]container.Summary, appInstall *model.AppInstall, force bool) { func synAppInstall(containers map[string]container.Summary, appInstall *model.AppInstall, force bool) {
@@ -1489,6 +1552,11 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
} }
for _, installed := range appInstallList { for _, installed := range appInstallList {
if sync && appInstallOperationPending(installed.Status) {
if err := syncAppInstallStatus(&installed, false); err != nil {
return nil, err
}
}
if updated && ignoreUpdate(installed) { if updated && ignoreUpdate(installed) {
continue continue
} }
@@ -1554,11 +1622,14 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
var versions []string var versions []string
for _, appDetail := range details { for _, appDetail := range details {
if !isAIGatewayUpgradeVersionAllowed(installed.App.Key, installed.Version, appDetail.Version) {
continue
}
ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(appDetail.ID), appIgnoreUpgradeRepo.WithScope("version")) ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(appDetail.ID), appIgnoreUpgradeRepo.WithScope("version"))
if len(ignores) > 0 { if len(ignores) > 0 {
continue continue
} }
if common.IsCrossVersion(installed.Version, appDetail.Version) && !installed.App.CrossVersionUpdate { if isCrossAppVersion(installed.App.Key, installed.Version, appDetail.Version) && !installed.App.CrossVersionUpdate {
continue continue
} }
versions = append(versions, appDetail.Version) versions = append(versions, appDetail.Version)
@@ -1573,7 +1644,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
continue continue
} }
versions = common.GetSortedVersions(versions) versions = sortAppVersions(installed.App.Key, versions)
lastVersion := versions[0] lastVersion := versions[0]
if installed.App.Key == constant.AppMysql || installed.App.Key == constant.AppMysqlCluster { if installed.App.Key == constant.AppMysql || installed.App.Key == constant.AppMysqlCluster {
@@ -1588,10 +1659,10 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
} }
} }
if common.IsCrossVersion(installed.Version, lastVersion) { if isCrossAppVersion(installed.App.Key, installed.Version, lastVersion) {
installDTO.CanUpdate = installed.App.CrossVersionUpdate installDTO.CanUpdate = installed.App.CrossVersionUpdate
} else { } else {
installDTO.CanUpdate = common.CompareVersion(lastVersion, installed.Version) installDTO.CanUpdate = isNewerAppVersion(installed.App.Key, lastVersion, installed.Version)
} }
if updated { if updated {
@@ -1964,6 +2035,9 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error { func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") { if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") {
if req.MCPPreserveWebsiteDir {
return prepareMCPWebsiteDirectory(req)
}
if dir, ok := req.Params["WEBSITE_DIR"]; ok { if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string) siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") { if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
@@ -2262,7 +2336,12 @@ func getAppVersions(key string, details []model.AppDetail) []string {
} }
versionsRaw = append(versionsRaw, detail.Version) versionsRaw = append(versionsRaw, detail.Version)
} }
versions = common.GetSortedVersions(versionsRaw) if isAIGatewayApp(key) {
sortAIGatewayVersions(versionsRaw)
versions = versionsRaw
} else {
versions = sortAppVersions(key, versionsRaw)
}
if hasLatest { if hasLatest {
versions = append([]string{latestVersion}, versions...) versions = append([]string{latestVersion}, versions...)
} }
+22 -6
View File
@@ -132,11 +132,9 @@ func (u *BackupService) AppRecover(req dto.CommonRecover) error {
if !fileOp.Stat(req.File) { if !fileOp.Stat(req.File) {
return buserr.WithName("ErrFileNotFound", req.File) return buserr.WithName("ErrFileNotFound", req.File)
} }
if _, err := compose.Down(install.GetComposePath()); err != nil {
return err
}
if err := handleAppRecover(&install, nil, req.File, false, req.Secret, req.TaskID); err != nil { if err := handleAppRecover(&install, nil, req.File, false, req.Secret, req.TaskID); err != nil {
global.LOG.Errorf("recover app %s failed, err: %v", req.DetailName, err) global.LOG.Errorf("recover app %s failed, err: %v", req.DetailName, err)
return err
} }
return nil return nil
} }
@@ -200,8 +198,20 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
} }
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string) error { func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string) error {
// Validate AI Gateway backups before stopping the app, creating rollback
// backups, or touching its files/database. Keep the validated extraction for
// the restore so uploaded/encrypted archives are not unpacked twice.
preparedPath, cleanup, err := prepareAIGatewayAppRecover(install, recoverFile, secret)
if err != nil {
return err
}
if parentTask == nil && !isRollback {
if _, err := compose.Down(install.GetComposePath()); err != nil {
cleanup()
return err
}
}
var ( var (
err error
recoverTask *task.Task recoverTask *task.Task
isOk = false isOk = false
rollbackFile string rollbackFile string
@@ -210,11 +220,13 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
if parentTask == nil { if parentTask == nil {
recoverTask, err = task.NewTaskWithOps(install.Name, task.TaskRecover, task.TaskScopeBackup, taskID, install.ID) recoverTask, err = task.NewTaskWithOps(install.Name, task.TaskRecover, task.TaskScopeBackup, taskID, install.ID)
if err != nil { if err != nil {
cleanup()
return err return err
} }
} }
recoverApp := func(t *task.Task) error { recoverApp := func(t *task.Task) error {
defer cleanup()
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !isRollback { if !isRollback {
rollbackFile = path.Join(global.Dir.TmpDir, fmt.Sprintf("app/%s_%s.tar.gz", install.Name, time.Now().Format(constant.DateTimeSlimLayout))) rollbackFile = path.Join(global.Dir.TmpDir, fmt.Sprintf("app/%s_%s.tar.gz", install.Name, time.Now().Format(constant.DateTimeSlimLayout)))
@@ -223,10 +235,13 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
} }
} }
tmpPath := preparedPath
if tmpPath == "" {
if err := fileOp.TarGzExtractPro(recoverFile, path.Dir(recoverFile), secret); err != nil { if err := fileOp.TarGzExtractPro(recoverFile, path.Dir(recoverFile), secret); err != nil {
return err return err
} }
tmpPath := strings.ReplaceAll(recoverFile, ".tar.gz", "") tmpPath = strings.ReplaceAll(recoverFile, ".tar.gz", "")
}
if err := fileOp.TarGzExtractPro(tmpPath+"/app.tar.gz", tmpPath, ""); err != nil { if err := fileOp.TarGzExtractPro(tmpPath+"/app.tar.gz", tmpPath, ""); err != nil {
return err return err
} }
@@ -236,7 +251,7 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
} else { } else {
_, _ = compose.Up(install.GetComposePath()) _, _ = compose.Up(install.GetComposePath())
} }
_ = os.RemoveAll(strings.ReplaceAll(recoverFile, ".tar.gz", "")) _ = os.RemoveAll(tmpPath)
}() }()
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") { if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
@@ -397,6 +412,7 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
} }
recoverTask.AddSubTask(task.GetTaskName(install.Name, task.TaskRecover, task.TaskScopeBackup), recoverApp, rollBackApp) recoverTask.AddSubTask(task.GetTaskName(install.Name, task.TaskRecover, task.TaskScopeBackup), recoverApp, rollBackApp)
go func() { go func() {
defer cleanup()
_ = recoverTask.Execute() _ = recoverTask.Execute()
}() }()
return nil return nil
+80 -44
View File
@@ -371,43 +371,39 @@ func loadComposePathAndFiles(composeName string, dockerClient *client.Client) (s
} }
config := targetContainer.Labels[composeConfigLabel] config := targetContainer.Labels[composeConfigLabel]
workdir := targetContainer.Labels[composeWorkdirLabel] workdir := targetContainer.Labels[composeWorkdirLabel]
if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) { files, err := resolveComposeFiles(workdir, config)
composePath = config if err != nil {
} else { return "", nil, err
composePath = workdir
} }
composePath = strings.Join(files, ",")
} }
composeFiles := normalizeComposeFiles(composePath) composeFiles, err := normalizeComposeFiles(composePath)
if len(composeFiles) == 0 { if err != nil {
return "", nil, fmt.Errorf("compose file not found for %s", composeName) return "", nil, err
} }
return composePath, composeFiles, nil return strings.Join(composeFiles, ","), composeFiles, nil
} }
func normalizeComposeFiles(composePath string) []string { func normalizeComposeFiles(composePath string) ([]string, error) {
items := strings.Split(composePath, ",") files, err := resolveComposeFiles("", composePath)
result := make([]string, 0) if err != nil {
seen := make(map[string]struct{}) return nil, err
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
} }
stat, err := os.Stat(item) for i, file := range files {
if err == nil && stat.IsDir() { info, err := os.Stat(file)
item = path.Join(item, "docker-compose.yml") if err == nil && info.IsDir() {
file = filepath.Join(file, "docker-compose.yml")
info, err = os.Stat(file)
files[i] = file
} }
if _, err := os.Stat(item); err != nil { if err != nil {
continue return nil, err
} }
if _, ok := seen[item]; ok { if !info.Mode().IsRegular() {
continue return nil, fmt.Errorf("compose path %s is not a regular file", file)
} }
seen[item] = struct{}{}
result = append(result, item)
} }
sort.Strings(result) return files, nil
return result
} }
func (c *composeBackupContext) close() { func (c *composeBackupContext) close() {
@@ -621,20 +617,46 @@ func safeJoinWithinBase(baseDir, name string) (string, error) {
} }
func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error { func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
if recoverCtx.targetDir != "" { composeFiles := recoverCtx.meta.Files
_ = os.RemoveAll(recoverCtx.targetDir) if strings.TrimSpace(recoverCtx.meta.ComposePath) != "" {
filesByPath := make(map[string]composeBackupFile, len(composeFiles))
for _, item := range composeFiles {
filesByPath[filepath.Clean(item.OriginalPath)] = item
} }
if err := os.MkdirAll(recoverCtx.targetDir, os.ModePerm); err != nil { orderedFiles := make([]composeBackupFile, 0, len(composeFiles))
return err for _, file := range strings.Split(recoverCtx.meta.ComposePath, ",") {
file = strings.TrimSpace(file)
if file == "" {
continue
} }
restored := make([]string, 0, len(recoverCtx.meta.Files)) file = filepath.Clean(file)
for _, item := range recoverCtx.meta.Files { item, ok := filesByPath[file]
if !ok {
if len(composeFiles) != 1 || file != filepath.Dir(composeFiles[0].OriginalPath) {
return fmt.Errorf("compose file %s is missing from backup metadata", file)
}
item = composeFiles[0]
}
orderedFiles = append(orderedFiles, item)
}
composeFiles = orderedFiles
}
if len(composeFiles) == 0 {
return fmt.Errorf("compose files are missing from backup metadata")
}
restored := make([]string, 0, len(composeFiles))
sourceByTarget := make(map[string]string, len(composeFiles))
for _, item := range composeFiles {
backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath) backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath)
if err != nil { if err != nil {
return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err) return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err)
} }
if !recoverCtx.fileOp.Stat(backupPath) { info, err := os.Stat(backupPath)
continue if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("compose backup path %s is not a regular file", backupPath)
} }
targetName := item.FileName targetName := item.FileName
if item.RelativePath != "" { if item.RelativePath != "" {
@@ -650,6 +672,29 @@ func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
if err != nil { if err != nil {
return fmt.Errorf("invalid compose target path %q, err: %v", targetName, err) return fmt.Errorf("invalid compose target path %q, err: %v", targetName, err)
} }
sourcePath := item.OriginalPath
if sourcePath == "" {
sourcePath = backupPath
}
sourcePath = filepath.Clean(sourcePath)
if previous, ok := sourceByTarget[targetPath]; ok && previous != sourcePath {
return fmt.Errorf("compose files %q and %q resolve to the same restore path %q", previous, sourcePath, targetPath)
}
sourceByTarget[targetPath] = sourcePath
restored = append(restored, targetPath)
}
if recoverCtx.targetDir != "" {
_ = os.RemoveAll(recoverCtx.targetDir)
}
if err := os.MkdirAll(recoverCtx.targetDir, os.ModePerm); err != nil {
return err
}
for i, item := range composeFiles {
backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath)
if err != nil {
return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err)
}
targetPath := restored[i]
if err := os.MkdirAll(path.Dir(targetPath), os.ModePerm); err != nil { if err := os.MkdirAll(path.Dir(targetPath), os.ModePerm); err != nil {
return err return err
} }
@@ -660,7 +705,6 @@ func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
if err := recoverCtx.fileOp.SaveFile(targetPath, string(content), fs.ModePerm); err != nil { if err := recoverCtx.fileOp.SaveFile(targetPath, string(content), fs.ModePerm); err != nil {
return err return err
} }
restored = append(restored, targetPath)
} }
envPath := path.Join(recoverCtx.tmpDir, "compose_files", ".env") envPath := path.Join(recoverCtx.tmpDir, "compose_files", ".env")
if recoverCtx.fileOp.Stat(envPath) { if recoverCtx.fileOp.Stat(envPath) {
@@ -672,14 +716,6 @@ func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
return err return err
} }
} }
if len(restored) == 0 {
defaultPath := path.Join(recoverCtx.targetDir, "docker-compose.yml")
if !recoverCtx.fileOp.Stat(defaultPath) {
return fmt.Errorf("compose file not found in backup data")
}
restored = append(restored, defaultPath)
}
sort.Strings(restored)
recoverCtx.composePath = strings.Join(restored, ",") recoverCtx.composePath = strings.Join(restored, ",")
return nil return nil
} }
+113 -78
View File
@@ -58,10 +58,10 @@ type ContainerService struct{}
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>") var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
type IContainerService interface { type IContainerService interface {
Page(req dto.PageContainer) (int64, interface{}, error) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error)
List() []dto.ContainerOptions List() []dto.ContainerOptions
ListByImage(imageName string) []dto.ContainerOptions ListByImage(imageName string) []dto.ContainerOptions
LoadStatus() (dto.ContainerStatus, error) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error)
PageNetwork(req dto.SearchWithPage) (int64, interface{}, error) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
ListNetwork() ([]dto.Options, error) ListNetwork() ([]dto.Options, error)
PageVolume(req dto.SearchWithPage) (int64, interface{}, error) PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
@@ -80,7 +80,7 @@ type IContainerService interface {
ContainerUpdate(req dto.ContainerOperate) error ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error) ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
ContainerListStats() ([]dto.ContainerListStats, error) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error)
ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error) ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
LoadResourceLimit() (*dto.ResourceLimit, error) LoadResourceLimit() (*dto.ResourceLimit, error)
ContainerRename(req dto.ContainerRename) error ContainerRename(req dto.ContainerRename) error
@@ -112,7 +112,7 @@ func NewIContainerService() IContainerService {
return &ContainerService{} return &ContainerService{}
} }
func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) { func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
@@ -123,7 +123,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
options.Filters = filters.NewArgs() options.Filters = filters.NewArgs()
options.Filters.Add("label", req.Filters) options.Filters.Add("label", req.Filters)
} }
containers, err := client.ContainerList(context.Background(), options) containers, err := client.ContainerList(ctx, options)
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
@@ -206,27 +206,30 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions
return options return options
} }
func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) { func (u *ContainerService) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) {
var data dto.ContainerStatus var data dto.ContainerStatus
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return data, err return data, err
} }
defer client.Close() defer client.Close()
c := context.Background() if !containersOnly {
images, _ := client.ImageList(ctx, image.ListOptions{All: true})
images, _ := client.ImageList(c, image.ListOptions{All: true})
data.ImageCount = len(images) data.ImageCount = len(images)
repo, _ := imageRepoRepo.List() repo, _ := imageRepoRepo.List()
data.RepoCount = len(repo) data.RepoCount = len(repo)
templates, _ := composeRepo.List() templates, _ := composeRepo.List()
data.ComposeTemplateCount = len(templates) data.ComposeTemplateCount = len(templates)
networks, _ := client.NetworkList(c, network.ListOptions{}) networks, _ := client.NetworkList(ctx, network.ListOptions{})
data.NetworkCount = len(networks) data.NetworkCount = len(networks)
volumes, _ := client.VolumeList(c, volume.ListOptions{}) volumes, _ := client.VolumeList(ctx, volume.ListOptions{})
data.VolumeCount = len(volumes.Volumes) data.VolumeCount = len(volumes.Volumes)
data.ComposeCount = loadComposeCount(client) data.ComposeCount = loadComposeCount(client)
containers, _ := client.ContainerList(c, container.ListOptions{All: true}) }
containers, err := client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return data, err
}
data.ContainerCount = len(containers) data.ContainerCount = len(containers)
for _, item := range containers { for _, item := range containers {
switch item.State { switch item.State {
@@ -292,25 +295,48 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera
} }
return data, nil return data, nil
} }
func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error) { func (u *ContainerService) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer client.Close() defer client.Close()
list, err := client.ContainerList(context.Background(), container.ListOptions{All: true}) options := container.ListOptions{All: true}
if ids != nil {
if len(ids) == 0 {
return []dto.ContainerListStats{}, nil
}
options.Filters = filters.NewArgs()
for _, id := range ids {
options.Filters.Add("id", id)
}
}
list, err := client.ContainerList(ctx, options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
datas := make([]dto.ContainerListStats, len(list)) datas := make([]dto.ContainerListStats, len(list))
jobs := make(chan int)
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(len(list)) for worker := 0; worker < min(8, len(list)); worker++ {
for i := 0; i < len(list); i++ { wg.Add(1)
go func(index int, item container.Summary) { go func() {
datas[index] = loadCpuAndMem(client, item.ID) defer wg.Done()
wg.Done() for index := range jobs {
}(i, list[i]) datas[index].ContainerID = list[index].ID
if ctx.Err() == nil && list[index].State == "running" {
datas[index] = loadCpuAndMem(ctx, client, list[index].ID)
} }
if len(list[index].Names) > 0 {
datas[index].ContainerName = strings.TrimPrefix(list[index].Names[0], "/")
}
}
}()
}
for index := range list {
jobs <- index
}
close(jobs)
wg.Wait() wg.Wait()
return datas, nil return datas, nil
} }
@@ -326,40 +352,47 @@ func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) {
case "container": case "container":
inspectInfo, err = client.ContainerInspect(context.Background(), req.ID) inspectInfo, err = client.ContainerInspect(context.Background(), req.ID)
case "compose": case "compose":
filePath := "" config, workdir := "", ""
cli, err := docker.NewDockerClient()
if err != nil {
return "", err
}
defer cli.Close()
options := container.ListOptions{All: true} options := container.ListOptions{All: true}
options.Filters = filters.NewArgs() options.Filters = filters.NewArgs()
options.Filters.Add("label", fmt.Sprintf("%s=%s", composeProjectLabel, req.ID)) options.Filters.Add("label", fmt.Sprintf("%s=%s", composeProjectLabel, req.ID))
containers, err := cli.ContainerList(context.Background(), options) containers, err := client.ContainerList(context.Background(), options)
if err != nil { if err != nil {
return "", err return "", err
} }
for _, container := range containers {
config := container.Labels[composeConfigLabel]
if len(req.Detail) != 0 && strings.Contains(config, req.Detail) {
config = req.Detail
}
workdir := container.Labels[composeWorkdirLabel]
if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) {
filePath = config
break
} else {
filePath = workdir
break
}
}
if len(containers) == 0 { if len(containers) == 0 {
composeItem, _ := composeRepo.GetRecord(repo.WithByName(req.ID)) composeItem, err := composeRepo.GetRecord(repo.WithByName(req.ID))
filePath = composeItem.Path if err != nil {
}
if _, err := os.Stat(filePath); err != nil {
return "", err return "", err
} }
config = composeItem.Path
workdir = filepath.Dir(primaryComposePath(config))
} else {
config = containers[0].Labels[composeConfigLabel]
workdir = containers[0].Labels[composeWorkdirLabel]
}
composeFiles, err := resolveComposeFiles(workdir, config)
if err != nil {
return "", err
}
filePath := composeFiles[0]
if strings.TrimSpace(req.Detail) != "" {
selectedFiles, err := resolveComposeFiles(workdir, req.Detail)
if err != nil {
return "", err
}
if len(selectedFiles) != 1 || !slices.Contains(composeFiles, selectedFiles[0]) {
return "", fmt.Errorf("compose file %s does not belong to project %s", req.Detail, req.ID)
}
filePath = selectedFiles[0]
}
info, err := os.Stat(filePath)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("compose path %s is not a regular file", filePath)
}
content, err := os.ReadFile(filePath) content, err := os.ReadFile(filePath)
if err != nil { if err != nil {
return "", err return "", err
@@ -383,11 +416,6 @@ func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) {
} }
func (u *ContainerService) Prune(req dto.ContainerPrune) error { func (u *ContainerService) Prune(req dto.ContainerPrune) error {
client, err := docker.NewDockerClient()
if err != nil {
return err
}
defer client.Close()
name := "" name := ""
switch req.PruneType { switch req.PruneType {
case "container": case "container":
@@ -408,6 +436,14 @@ func (u *ContainerService) Prune(req dto.ContainerPrune) error {
} }
taskItem.AddSubTask(i18n.GetMsgByKey("TaskClean"), func(t *task.Task) error { taskItem.AddSubTask(i18n.GetMsgByKey("TaskClean"), func(t *task.Task) error {
if err := t.TaskCtx.Err(); err != nil {
return err
}
client, err := docker.NewDockerClient()
if err != nil {
return err
}
defer client.Close()
pruneFilters := filters.NewArgs() pruneFilters := filters.NewArgs()
if req.WithTagAll { if req.WithTagAll {
pruneFilters.Add("dangling", "false") pruneFilters.Add("dangling", "false")
@@ -431,10 +467,7 @@ func (u *ContainerService) Prune(req dto.ContainerPrune) error {
} }
SpaceReclaimed = int(rep.SpaceReclaimed) SpaceReclaimed = int(rep.SpaceReclaimed)
case "network": case "network":
_, err := client.NetworksPrune(context.Background(), pruneFilters) return cleanUnusedNetworks(t, client)
if err != nil {
return err
}
case "volume": case "volume":
versions, err := client.ServerVersion(context.Background()) versions, err := client.ServerVersion(context.Background())
if err != nil { if err != nil {
@@ -803,33 +836,33 @@ func (u *ContainerService) ContainerLogClean(req dto.OperationWithName) error {
func (u *ContainerService) StreamLogs(ctx *gin.Context, params dto.StreamLog) { func (u *ContainerService) StreamLogs(ctx *gin.Context, params dto.StreamLog) {
messageChan := make(chan string, 1024) messageChan := make(chan string, 1024)
errorChan := make(chan error, 1) errorChan := make(chan error, 1)
doneChan := make(chan struct{}) logCtx, cancel := context.WithCancel(ctx.Request.Context())
defer cancel()
go func() { doneChan := logCtx.Done()
<-ctx.Request.Context().Done()
close(doneChan)
}()
go collectLogs(doneChan, params, messageChan, errorChan) go collectLogs(doneChan, params, messageChan, errorChan)
ctx.Stream(func(w io.Writer) bool { ctx.Stream(func(w io.Writer) bool {
select { return writeContainerLogMessage(w, doneChan, messageChan, errorChan)
case msg, ok := <-messageChan:
if !ok {
return false
}
_, err := fmt.Fprintf(w, "data: %s\n\n", msg)
if err != nil {
return false
}
return true
case err := <-errorChan:
if err != nil {
_, _ = fmt.Fprintf(w, "event: error\ndata: %v\n\n", err.Error())
}
return false
}
}) })
} }
func writeContainerLogMessage(w io.Writer, done <-chan struct{}, messages <-chan string, errors <-chan error) bool {
select {
case <-done:
return false
case message, ok := <-messages:
if ok {
_, err := fmt.Fprintf(w, "data: %s\n\n", message)
return err == nil
}
// The collector closes both channels when it finishes. Drain all buffered
// logs before handling its result, or a ready error channel can drop the tail.
if err := <-errors; err != nil {
_, _ = fmt.Fprintf(w, "event: error\ndata: %v\n\n", err)
}
return false
}
}
func collectLogs(done <-chan struct{}, params dto.StreamLog, messageChan chan<- string, errorChan chan<- error) { func collectLogs(done <-chan struct{}, params dto.StreamLog, messageChan chan<- string, errorChan chan<- error) {
defer close(messageChan) defer close(messageChan)
defer close(errorChan) defer close(errorChan)
@@ -1715,11 +1748,13 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo
return selected return selected
} }
func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerListStats { func loadCpuAndMem(ctx context.Context, client *client.Client, containerItem string) dto.ContainerListStats {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
data := dto.ContainerListStats{ data := dto.ContainerListStats{
ContainerID: containerItem, ContainerID: containerItem,
} }
res, err := client.ContainerStats(context.Background(), containerItem, false) res, err := client.ContainerStats(ctx, containerItem, false)
if err != nil { if err != nil {
return data return data
} }
+71 -49
View File
@@ -71,10 +71,9 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
ContainerCount: 0, ContainerCount: 0,
CreatedAt: localItem.CreatedAt.Format(constant.DateTimeLayout), CreatedAt: localItem.CreatedAt.Format(constant.DateTimeLayout),
ConfigFile: localItem.Path, ConfigFile: localItem.Path,
Workdir: strings.TrimSuffix(localItem.Path, "/docker-compose.yml"), Workdir: filepath.Dir(primaryComposePath(localItem.Path)),
} }
composeItemLocal.CreatedBy = "1Panel" composeItemLocal.CreatedBy = "1Panel"
composeItemLocal.Path = localItem.Path
composeLocalMap[localItem.Name] = composeItemLocal composeLocalMap[localItem.Name] = composeItemLocal
} }
@@ -112,11 +111,6 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
if ok { if ok {
composeItem.CreatedBy = createdBy composeItem.CreatedBy = createdBy
} }
if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) {
composeItem.Path = config
} else {
composeItem.Path = workdir
}
for i := 0; i < len(composeCreatedByLocal); i++ { for i := 0; i < len(composeCreatedByLocal); i++ {
if composeCreatedByLocal[i].Name == name { if composeCreatedByLocal[i].Name == name {
composeItem.CreatedBy = "1Panel" composeItem.CreatedBy = "1Panel"
@@ -147,7 +141,9 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
for key, value := range mergedMap { for key, value := range mergedMap {
value.Name = key value.Name = key
value.ComposeFileExists = composeFileExists(value.Workdir, value.ConfigFile) composeFiles, err := resolveComposeFiles(value.Workdir, value.ConfigFile)
value.Path = strings.Join(composeFiles, ",")
value.ComposeFileExists = err == nil && composeFileExists(composeFiles)
value.IsPinned = pinnedByName[key] value.IsPinned = pinnedByName[key]
records = append(records, value) records = append(records, value)
} }
@@ -192,29 +188,6 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
return int64(total), listItem, nil return int64(total), listItem, nil
} }
func composeFileExists(workdir, configFile string) bool {
workdir = strings.TrimSpace(workdir)
configFile = strings.TrimSpace(configFile)
if configFile == "" {
return false
}
for _, item := range strings.Split(configFile, ",") {
file := strings.TrimSpace(item)
if file == "" {
continue
}
if !filepath.IsAbs(file) && workdir != "" {
file = filepath.Join(workdir, file)
}
file = filepath.Clean(file)
info, err := os.Stat(file)
if err == nil && !info.IsDir() {
return true
}
}
return false
}
func (u *ContainerService) TestCompose(req dto.ComposeCreate) (bool, error) { func (u *ContainerService) TestCompose(req dto.ComposeCreate) (bool, error) {
if err := validateComposeCreateName(req); err != nil { if err := validateComposeCreateName(req); err != nil {
return false, err return false, err
@@ -522,10 +495,6 @@ func isComposeProjectNameEmptyError(err error) bool {
} }
func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error { func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
if len(req.Path) == 0 && req.Operation == "delete" {
_ = composeRepo.DeleteRecord(repo.WithByName(req.Name))
return nil
}
if cmd.CheckIllegal(req.Path, req.Operation) { if cmd.CheckIllegal(req.Path, req.Operation) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -535,13 +504,19 @@ func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
} }
if req.WithFile { if req.WithFile {
for _, item := range strings.Split(req.Path, ",") { for _, item := range strings.Split(req.Path, ",") {
if len(item) != 0 { item = strings.TrimSpace(item)
_ = os.RemoveAll(path.Dir(item)) if !filepath.IsAbs(item) || filepath.Dir(item) == "/" {
continue
}
info, err := os.Stat(item)
if err == nil && info.Mode().IsRegular() {
if err := os.RemoveAll(filepath.Dir(item)); err != nil && !req.Force {
return err
} }
} }
} }
_ = composeRepo.DeleteRecord(repo.WithByName(req.Name)) }
return nil return composeRepo.DeleteRecord(repo.WithByName(req.Name))
} }
if req.Operation == "up" { if req.Operation == "up" {
if stdout, err := compose.Up(req.Path, req.Name); err != nil { if stdout, err := compose.Up(req.Path, req.Name); err != nil {
@@ -584,13 +559,13 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error {
write.Flush() write.Flush()
global.LOG.Infof("docker-compose.yml %s has been replaced, now start to docker-compose restart", req.DetailPath) global.LOG.Infof("docker-compose.yml %s has been replaced, now start to docker-compose restart", req.DetailPath)
if err := newComposeEnv(req.DetailPath, req.Env); err != nil { if err := newComposeEnv(primaryComposePath(req.Path), req.Env); err != nil {
return err return err
} }
if err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name); err != nil { if err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name); err != nil {
global.LOG.Errorf("update failed when handle compose up, err: %s, now try to recreate the old compose file", err) global.LOG.Errorf("update failed when handle compose up, err: %s, now try to recreate the old compose file", err)
if err := recreateCompose(string(oldFile), req.Path, req.Name); err != nil { if err := recreateCompose(string(oldFile), req.Path, req.DetailPath, req.Name); err != nil {
return fmt.Errorf("update failed and recreate old compose file also failed, err: %v", err) return fmt.Errorf("update failed and recreate old compose file also failed, err: %v", err)
} }
return fmt.Errorf("update failed when handle compose up, err: %s", err) return fmt.Errorf("update failed when handle compose up, err: %s", err)
@@ -702,9 +677,18 @@ func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
} }
func removeContainerForCompose(composeName, composePath string) error { func removeContainerForCompose(composeName, composePath string) error {
if _, err := os.Stat(composePath); err == nil { composeFiles, err := resolveComposeFiles("", composePath)
if stdout, err := compose.Operate(composePath, "down", composeName); err != nil { filesAvailable := err == nil
return errors.New(stdout) for _, file := range composeFiles {
info, err := os.Stat(file)
if err != nil || !info.Mode().IsRegular() {
filesAvailable = false
break
}
}
if filesAvailable {
if stdout, err := compose.Operate(strings.Join(composeFiles, ","), "down", composeName); err != nil {
return fmt.Errorf("docker-compose down failed, std: %s, err: %w", stdout, err)
} }
return nil return nil
} }
@@ -722,14 +706,17 @@ func removeContainerForCompose(composeName, composePath string) error {
if err != nil { if err != nil {
return err return err
} }
var removeErrors []error
for _, c := range containers { for _, c := range containers {
_ = client.ContainerRemove(ctx, c.ID, container.RemoveOptions{RemoveVolumes: true, Force: true}) if err := client.ContainerRemove(ctx, c.ID, container.RemoveOptions{RemoveVolumes: true, Force: true}); err != nil {
removeErrors = append(removeErrors, fmt.Errorf("remove container %s failed: %w", c.ID, err))
} }
return nil }
return errors.Join(removeErrors...)
} }
func recreateCompose(content, path, projectName string) error { func recreateCompose(content, composePath, detailPath, projectName string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0640) file, err := os.OpenFile(detailPath, os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil { if err != nil {
return err return err
} }
@@ -738,7 +725,7 @@ func recreateCompose(content, path, projectName string) error {
_, _ = write.WriteString(content) _, _ = write.WriteString(content)
write.Flush() write.Flush()
if stdout, err := compose.Up(path, projectName); err != nil { if stdout, err := compose.Up(composePath, projectName); err != nil {
return errors.New(string(stdout)) return errors.New(string(stdout))
} }
return nil return nil
@@ -746,6 +733,9 @@ func recreateCompose(content, path, projectName string) error {
func loadEnv(list []dto.ComposeInfo) []dto.ComposeInfo { func loadEnv(list []dto.ComposeInfo) []dto.ComposeInfo {
for i := 0; i < len(list); i++ { for i := 0; i < len(list); i++ {
if list[i].Path == "" {
continue
}
tmpPath := list[i].Path tmpPath := list[i].Path
if strings.Contains(list[i].Path, ",") { if strings.Contains(list[i].Path, ",") {
tmpPath = strings.Split(list[i].Path, ",")[0] tmpPath = strings.Split(list[i].Path, ",")[0]
@@ -774,3 +764,35 @@ func newComposeEnv(pathItem string, env string) error {
} }
return nil return nil
} }
func resolveComposeFiles(workdir, configFile string) ([]string, error) {
workdir = strings.TrimSpace(workdir)
var files []string
for _, item := range strings.Split(configFile, ",") {
file := strings.TrimSpace(item)
if file == "" {
continue
}
if !filepath.IsAbs(file) {
if !filepath.IsAbs(workdir) {
return nil, fmt.Errorf("cannot resolve compose file %s without an absolute working directory", file)
}
file = filepath.Join(workdir, file)
}
files = append(files, filepath.Clean(file))
}
if len(files) == 0 {
return nil, fmt.Errorf("compose config file is not specified")
}
return files, nil
}
func composeFileExists(files []string) bool {
for _, file := range files {
info, err := os.Stat(file)
if err == nil && info.Mode().IsRegular() {
return true
}
}
return false
}
+119
View File
@@ -2,16 +2,24 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
) )
var networkCleanupSlot = make(chan struct{}, 1)
func (u *ContainerService) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error) { func (u *ContainerService) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error) {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
@@ -172,3 +180,114 @@ func (u *ContainerService) CreateNetwork(req dto.NetworkCreate) error {
} }
return nil return nil
} }
func cleanUnusedNetworks(t *task.Task, cli *client.Client) error {
ctx := t.TaskCtx
select {
case networkCleanupSlot <- struct{}{}:
defer func() { <-networkCleanupSlot }()
case <-ctx.Done():
return ctx.Err()
}
if err := ctx.Err(); err != nil {
return err
}
networks, err := cli.NetworkList(ctx, network.ListOptions{})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
deleted, skipped, failed := 0, 0, 0
defer func() {
t.Log(i18n.GetMsgWithMap("NetworkCleanupSummary", map[string]interface{}{
"deleted": deleted, "skipped": skipped, "failed": failed,
}))
}()
var used map[string]bool
sort.Slice(networks, func(i, j int) bool { return networks[i].Name < networks[j].Name })
for _, n := range networks {
if err := ctx.Err(); err != nil {
return err
}
switch n.Name {
case "none", "host", "bridge", "1panel-network":
skipped++
continue
}
if n.Scope != "local" || n.Ingress || n.ConfigOnly {
skipped++
continue
}
values := map[string]interface{}{"name": n.Name, "id": n.ID}
if used == nil {
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
used = make(map[string]bool)
for _, c := range containers {
if c.NetworkSettings == nil {
continue
}
for name, endpoint := range c.NetworkSettings.Networks {
used[name] = true
if endpoint != nil {
used[endpoint.NetworkID] = true
}
}
}
}
if used[n.Name] || used[n.ID] {
skipped++
t.Log(i18n.GetMsgWithMap("NetworkCleanupConnected", values))
continue
}
inspected, err := cli.NetworkInspect(ctx, n.ID, network.InspectOptions{})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if errdefs.IsNotFound(err) {
skipped++
} else {
failed++
t.Logf("Failed to inspect network [%s] (%s): %v", n.Name, n.ID, err)
}
continue
}
if len(inspected.Containers) > 0 {
skipped++
t.Log(i18n.GetMsgWithMap("NetworkCleanupConnected", values))
continue
}
if err := cli.NetworkRemove(ctx, n.ID); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
switch {
case errdefs.IsNotFound(err):
skipped++
case errdefs.IsConflict(err):
skipped++
t.Log(i18n.GetMsgWithMap("NetworkCleanupConnected", values))
default:
failed++
t.Logf("Failed to remove network [%s] (%s): %v", n.Name, n.ID, err)
}
continue
}
deleted++
}
if err := ctx.Err(); err != nil {
return err
}
if failed > 0 {
return errors.New(i18n.GetMsgByKey("NetworkCleanupPartialFailure"))
}
return nil
}
+75 -9
View File
@@ -16,6 +16,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
alertUtil "github.com/1Panel-dev/1Panel/agent/utils/alert"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/jinzhu/copier" "github.com/jinzhu/copier"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -75,6 +76,7 @@ func (u *CronjobService) SearchWithPage(search dto.PageCronjob) (int64, interfac
EntryID: cronjob.ID, EntryID: cronjob.ID,
} }
alertInfo, _ := alertRepo.Get(alertRepo.WithByType(alertBase.AlertType), alertRepo.WithByProject(strconv.Itoa(int(alertBase.EntryID))), repo.WithByStatus(constant.AlertEnable)) alertInfo, _ := alertRepo.Get(alertRepo.WithByType(alertBase.AlertType), alertRepo.WithByProject(strconv.Itoa(int(alertBase.EntryID))), repo.WithByStatus(constant.AlertEnable))
item.AlertTriggerMode, _ = alertUtil.CronJobAlertTriggerMode(alertInfo.AdvancedParams)
if alertInfo.SendCount != 0 { if alertInfo.SendCount != 0 {
item.AlertCount = alertInfo.SendCount item.AlertCount = alertInfo.SendCount
} else { } else {
@@ -98,9 +100,11 @@ func (u *CronjobService) LoadInfo(req dto.OperateByID) (*dto.CronjobOperate, err
AlertType: cronjob.Type, AlertType: cronjob.Type,
EntryID: cronjob.ID, EntryID: cronjob.ID,
} }
alertInfo, _ := alertRepo.Get(alertRepo.WithByType(alertBase.AlertType), alertRepo.WithByProject(strconv.Itoa(int(alertBase.EntryID))), repo.WithByStatus(constant.AlertEnable)) alertInfo, _ := alertRepo.Get(alertRepo.WithByType(alertBase.AlertType), alertRepo.WithByProject(strconv.Itoa(int(alertBase.EntryID))))
item.AlertMethod = alertInfo.Method item.AlertMethod = alertInfo.Method
if alertInfo.SendCount != 0 { item.AlertTitle = alertInfo.Title
item.AlertTriggerMode, _ = alertUtil.CronJobAlertTriggerMode(alertInfo.AdvancedParams)
if alertInfo.Status == constant.AlertEnable {
item.AlertCount = alertInfo.SendCount item.AlertCount = alertInfo.SendCount
} else { } else {
item.AlertCount = 0 item.AlertCount = 0
@@ -195,11 +199,12 @@ func (u *CronjobService) Export(req dto.OperateByIDs) (string, error) {
} }
} }
item.SourceAccounts, item.DownloadAccount, _ = loadBackupNamesByID(cronjob.SourceAccountIDs, cronjob.DownloadAccountID) item.SourceAccounts, item.DownloadAccount, _ = loadBackupNamesByID(cronjob.SourceAccountIDs, cronjob.DownloadAccountID)
alertInfo, _ := alertRepo.Get(alertRepo.WithByType(cronjob.Type), alertRepo.WithByProject(strconv.Itoa(int(cronjob.ID))), repo.WithByStatus(constant.AlertEnable)) alertInfo, _ := alertRepo.Get(alertRepo.WithByType(cronjob.Type), alertRepo.WithByProject(strconv.Itoa(int(cronjob.ID))))
if alertInfo.SendCount != 0 {
item.AlertCount = alertInfo.SendCount
item.AlertTitle = alertInfo.Title item.AlertTitle = alertInfo.Title
item.AlertMethod = alertInfo.Method item.AlertMethod = alertInfo.Method
item.AlertTriggerMode, _ = alertUtil.CronJobAlertTriggerMode(alertInfo.AdvancedParams)
if alertInfo.Status == constant.AlertEnable {
item.AlertCount = alertInfo.SendCount
} else { } else {
item.AlertCount = 0 item.AlertCount = 0
} }
@@ -213,6 +218,17 @@ func (u *CronjobService) Export(req dto.OperateByIDs) (string, error) {
} }
func (u *CronjobService) Import(req []dto.CronjobTrans, operator string) error { func (u *CronjobService) Import(req []dto.CronjobTrans, operator string) error {
for _, item := range req {
advanced, err := cronJobAlertAdvancedParams(item.AlertTriggerMode)
if err != nil {
return err
}
if item.AlertCount != 0 {
if err := (AlertService{}).validateCronJobAlertChannels(item.Type, advanced, item.AlertMethod); err != nil {
return err
}
}
}
for _, item := range req { for _, item := range req {
cronjobItem, _ := cronjobRepo.Get(repo.WithByName(item.Name)) cronjobItem, _ := cronjobRepo.Get(repo.WithByName(item.Name))
if cronjobItem.ID != 0 { if cronjobItem.ID != 0 {
@@ -395,17 +411,27 @@ func (u *CronjobService) Import(req []dto.CronjobTrans, operator string) error {
} else { } else {
cronjob.Status = constant.StatusDisable cronjob.Status = constant.StatusDisable
} }
_ = cronjobRepo.Create(&cronjob) if err := cronjobRepo.Create(&cronjob); err != nil {
if item.AlertCount != 0 && item.AlertTitle != "" && item.AlertMethod != "" { return err
}
if item.AlertTitle != "" && item.AlertMethod != "" {
advanced, _ := cronJobAlertAdvancedParams(item.AlertTriggerMode)
status := constant.AlertEnable
if item.AlertCount == 0 {
status = constant.AlertDisable
}
createAlert := dto.AlertCreate{ createAlert := dto.AlertCreate{
Title: item.AlertTitle, Title: item.AlertTitle,
SendCount: item.AlertCount, SendCount: item.AlertCount,
Method: item.AlertMethod, Method: item.AlertMethod,
Type: cronjob.Type, Type: cronjob.Type,
Project: strconv.Itoa(int(cronjob.ID)), Project: strconv.Itoa(int(cronjob.ID)),
Status: constant.AlertEnable, Status: status,
AdvancedParams: advanced,
}
if err := NewIAlertService().CreateAlert(createAlert, operator); err != nil {
return err
} }
_ = NewIAlertService().CreateAlert(createAlert, operator)
} }
} }
return nil return nil
@@ -562,6 +588,15 @@ func (u *CronjobService) HandleOnce(id uint) error {
} }
func (u *CronjobService) Create(req dto.CronjobOperate, operator string) error { func (u *CronjobService) Create(req dto.CronjobOperate, operator string) error {
advanced, err := cronJobAlertAdvancedParams(req.AlertTriggerMode)
if err != nil {
return err
}
if req.AlertCount != 0 {
if err := (AlertService{}).validateCronJobAlertChannels(req.Type, advanced, req.AlertMethod); err != nil {
return err
}
}
cronjob, _ := cronjobRepo.Get(repo.WithByName(req.Name)) cronjob, _ := cronjobRepo.Get(repo.WithByName(req.Name))
if cronjob.ID != 0 { if cronjob.ID != 0 {
return buserr.New("ErrRecordExist") return buserr.New("ErrRecordExist")
@@ -609,6 +644,7 @@ func (u *CronjobService) Create(req dto.CronjobOperate, operator string) error {
Type: cronjob.Type, Type: cronjob.Type,
Project: strconv.Itoa(int(cronjob.ID)), Project: strconv.Itoa(int(cronjob.ID)),
Status: constant.AlertEnable, Status: constant.AlertEnable,
AdvancedParams: advanced,
} }
err := NewIAlertService().CreateAlert(createAlert, operator) err := NewIAlertService().CreateAlert(createAlert, operator)
if err != nil { if err != nil {
@@ -682,6 +718,10 @@ func (u *CronjobService) Delete(req dto.CronjobBatchDelete) error {
} }
func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string) error { func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string) error {
advanced, err := cronJobAlertAdvancedParams(req.AlertTriggerMode)
if err != nil {
return err
}
var cronjob model.Cronjob var cronjob model.Cronjob
if err := copier.Copy(&cronjob, &req); err != nil { if err := copier.Copy(&cronjob, &req); err != nil {
return buserr.WithDetail("ErrStructTransform", err.Error(), nil) return buserr.WithDetail("ErrStructTransform", err.Error(), nil)
@@ -697,6 +737,20 @@ func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string
if err != nil { if err != nil {
return buserr.New("ErrRecordNotFound") return buserr.New("ErrRecordNotFound")
} }
if req.AlertCount != 0 {
previous, _ := alertRepo.Get(alertRepo.WithByType(cronModel.Type), alertRepo.WithByProject(strconv.Itoa(int(id))))
merged, err := prepareCronJobAlertParams(cronModel.Type, previous.AdvancedParams, advanced)
if err != nil {
return err
}
method := req.AlertMethod
if method == "" {
method = previous.Method
}
if err := (AlertService{}).validateCronJobAlertChannels(cronModel.Type, merged, method); err != nil {
return err
}
}
upMap := make(map[string]interface{}) upMap := make(map[string]interface{})
cronjob.EntryIDs = cronModel.EntryIDs cronjob.EntryIDs = cronModel.EntryIDs
cronjob.Type = cronModel.Type cronjob.Type = cronModel.Type
@@ -758,6 +812,7 @@ func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string
Method: req.AlertMethod, Method: req.AlertMethod,
Type: cronjob.Type, Type: cronjob.Type,
Project: strconv.Itoa(int(cronModel.ID)), Project: strconv.Itoa(int(cronModel.ID)),
AdvancedParams: advanced,
} }
err = NewIAlertService().ExternalUpdateAlert(updateAlert, operator) err = NewIAlertService().ExternalUpdateAlert(updateAlert, operator)
if err != nil { if err != nil {
@@ -766,6 +821,17 @@ func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string
return nil return nil
} }
func cronJobAlertAdvancedParams(mode string) (string, error) {
if mode == "" {
return "", nil
}
data, err := json.Marshal(map[string]string{"alertTriggerMode": mode})
if err != nil {
return "", err
}
return alertUtil.MergeCronJobAlertParams("", string(data))
}
func (u *CronjobService) UpdateStatus(id uint, status string) error { func (u *CronjobService) UpdateStatus(id uint, status string) error {
cronjob, _ := cronjobRepo.Get(repo.WithByID(id)) cronjob, _ := cronjobRepo.Get(repo.WithByID(id))
if cronjob.ID == 0 { if cronjob.ID == 0 {
+1
View File
@@ -412,6 +412,7 @@ func addSkipTask(source string, taskItem *task.Task) {
taskItem.Log(i18n.GetMsgByKey("NoSuchResource")) taskItem.Log(i18n.GetMsgByKey("NoSuchResource"))
return nil return nil
}, nil) }, nil)
taskItem.SubTasks[len(taskItem.SubTasks)-1].StepAlias = cronJobSkippedStep
} }
func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper { func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper {
+35 -6
View File
@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -23,6 +24,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
alertUtil "github.com/1Panel-dev/1Panel/agent/utils/alert"
"github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/ntp" "github.com/1Panel-dev/1Panel/agent/utils/ntp"
@@ -56,10 +58,11 @@ func (u *CronjobService) HandleJob(cronjob *model.Cronjob) {
_ = taskRepo.Save(context.Background(), taskItem.Task) _ = taskRepo.Save(context.Background(), taskItem.Task)
} }
cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records) cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records)
handleCronJobAlert(cronjob) handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, err))
return return
} }
cronjobRepo.EndRecords(record, constant.StatusSuccess, "", record.Records) cronjobRepo.EndRecords(record, constant.StatusSuccess, "", record.Records)
handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, nil))
}() }()
return return
} }
@@ -70,19 +73,20 @@ func (u *CronjobService) HandleJob(cronjob *model.Cronjob) {
record.TaskID = "" record.TaskID = ""
} }
cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records) cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records)
handleCronJobAlert(cronjob) handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, err))
return return
} }
go func() { go func() {
if err := taskItem.Execute(); err != nil { if err := taskItem.Execute(); err != nil {
taskItem, _ := taskRepo.GetFirst(taskRepo.WithByID(record.TaskID)) storedTask, _ := taskRepo.GetFirst(taskRepo.WithByID(record.TaskID))
if len(taskItem.ID) == 0 { if len(storedTask.ID) == 0 {
record.TaskID = "" record.TaskID = ""
} }
cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records) cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records)
handleCronJobAlert(cronjob) handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, err))
} else { } else {
cronjobRepo.EndRecords(record, constant.StatusSuccess, "", record.Records) cronjobRepo.EndRecords(record, constant.StatusSuccess, "", record.Records)
handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, nil))
} }
}() }()
} }
@@ -482,8 +486,33 @@ func hasBackup(cronjobType string) bool {
return cronjobType == "app" || cronjobType == "database" || cronjobType == "website" || cronjobType == "directory" || cronjobType == "snapshot" || cronjobType == "log" || cronjobType == "cutWebsiteLog" return cronjobType == "app" || cronjobType == "database" || cronjobType == "website" || cronjobType == "directory" || cronjobType == "snapshot" || cronjobType == "log" || cronjobType == "cutWebsiteLog"
} }
func handleCronJobAlert(cronjob *model.Cronjob) { const cronJobSkippedStep = "cronjob-skipped"
func cronJobAlertResult(taskItem *task.Task, err error) string {
if errors.Is(err, context.Canceled) || taskItem.Task.Status == constant.StatusCanceled ||
(taskItem.TaskCtx != nil && taskItem.TaskCtx.Err() != nil) {
return ""
}
if err != nil {
return alertUtil.CronJobAlertFailed
}
if taskItem.Task.Status != constant.StatusSuccess {
return ""
}
for _, subTask := range taskItem.SubTasks {
if subTask.StepAlias != cronJobSkippedStep {
return alertUtil.CronJobAlertSuccess
}
}
return ""
}
func handleCronJobAlert(cronjob *model.Cronjob, result string) {
if result == "" {
return
}
pushAlert := dto.PushAlert{ pushAlert := dto.PushAlert{
Result: result,
TaskName: cronjob.Name, TaskName: cronjob.Name,
AlertType: cronjob.Type, AlertType: cronjob.Type,
EntryID: cronjob.ID, EntryID: cronjob.ID,
+102 -58
View File
@@ -43,6 +43,10 @@ type IDashboardService interface {
LoadQuickOptions() []dto.QuickJump LoadQuickOptions() []dto.QuickJump
ChangeQuick(req dto.ChangeQuicks) error ChangeQuick(req dto.ChangeQuicks) error
ListQuickDirectories() ([]model.QuickDirectory, error)
CreateQuickDirectory(req dto.QuickDirectoryCreate) (*model.QuickDirectory, error)
UpdateQuickDirectory(req dto.QuickDirectoryUpdate) (*model.QuickDirectory, error)
DeleteQuickDirectory(req dto.QuickDirectoryDelete) error
LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher, error) LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher, error)
ChangeShow(req dto.SettingUpdate) error ChangeShow(req dto.SettingUpdate) error
@@ -242,7 +246,7 @@ func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *d
currentInfo.SwapMemoryUsed = swapInfo.Used currentInfo.SwapMemoryUsed = swapInfo.Used
currentInfo.SwapMemoryUsedPercent = swapInfo.UsedPercent currentInfo.SwapMemoryUsedPercent = swapInfo.UsedPercent
currentInfo.DiskData = loadDiskInfo() currentInfo.DiskData = loadDiskInfo(false)
currentInfo.GPUData, currentInfo.NPUData, currentInfo.XPUData = loadAcceleratorInfo() currentInfo.GPUData, currentInfo.NPUData, currentInfo.XPUData = loadAcceleratorInfo()
if ioOption == "all" { if ioOption == "all" {
@@ -397,16 +401,15 @@ func (u *DashboardService) LoadQuickOptions() []dto.QuickJump {
} }
func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error { func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error {
showCount := 0 showCount := 0
var quicks []model.QuickJump quicks := make([]model.QuickJump, 0, len(req.Quicks))
for _, item := range req.Quicks { for _, item := range req.Quicks {
var quick model.QuickJump
if item.IsShow { if item.IsShow {
showCount++ showCount++
} }
if err := copier.Copy(&quick, &item); err != nil { quicks = append(quicks, model.QuickJump{
return err BaseModel: model.BaseModel{ID: item.ID},
} IsShow: item.IsShow,
quicks = append(quicks, quick) })
} }
if showCount == 0 { if showCount == 0 {
return buserr.New("ErrMinQuickJump") return buserr.New("ErrMinQuickJump")
@@ -455,24 +458,9 @@ type diskInfo struct {
Device string Device string
} }
func loadDiskInfo() []dto.DiskInfo { func loadDiskInfo(forceRefresh bool) []dto.DiskInfo {
var datas []dto.DiskInfo var datas []dto.DiskInfo
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Second)) stdout := loadDiskMounts()
format := `NR>1 && !/tmpfs|snap\/core|udev/ {printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", $1, $2, $3, $4, $5, $6, $7}`
stdout, err := cmdMgr.RunPipe(
cmd.PipeCommand{Name: "df", Args: []string{"-hT", "-P"}},
cmd.PipeCommand{Name: "awk", Args: []string{format}},
)
if err != nil {
cmdMgr2 := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second))
stdout, err = cmdMgr2.RunPipe(
cmd.PipeCommand{Name: "df", Args: []string{"-lhT", "-P"}},
cmd.PipeCommand{Name: "awk", Args: []string{format}},
)
if err != nil {
return datas
}
}
lines := strings.Split(stdout, "\n") lines := strings.Split(stdout, "\n")
var mounts []diskInfo var mounts []diskInfo
@@ -520,43 +508,22 @@ func loadDiskInfo() []dto.DiskInfo {
itemData.Type = mount.Type itemData.Type = mount.Type
itemData.Device = mount.Device itemData.Device = mount.Device
type diskResult struct { state, err := loadDiskUsageWithTimeout(mount.Mount, forceRefresh)
state *disk.UsageStat if err != nil {
err error global.LOG.Errorf("load disk info from %s failed, err: %v", mount.Mount, err)
} else {
itemData.Total = state.Total
itemData.Free = state.Free
itemData.Used = state.Used
itemData.UsedPercent = state.UsedPercent
itemData.InodesTotal = state.InodesTotal
itemData.InodesUsed = state.InodesUsed
itemData.InodesFree = state.InodesFree
itemData.InodesUsedPercent = state.InodesUsedPercent
} }
resultCh := make(chan diskResult, 1)
go func() {
state, err := psutil.DISK.GetUsage(mount.Mount, false)
resultCh <- diskResult{state: state, err: err}
}()
select {
case <-time.After(5 * time.Second):
mu.Lock() mu.Lock()
datas = append(datas, itemData) datas = append(datas, itemData)
mu.Unlock() mu.Unlock()
global.LOG.Errorf("load disk info from %s failed, err: timeout", mount.Mount)
case result := <-resultCh:
if result.err != nil {
mu.Lock()
datas = append(datas, itemData)
mu.Unlock()
global.LOG.Errorf("load disk info from %s failed, err: %v", mount.Mount, result.err)
return
}
itemData.Total = result.state.Total
itemData.Free = result.state.Free
itemData.Used = result.state.Used
itemData.UsedPercent = result.state.UsedPercent
itemData.InodesTotal = result.state.InodesTotal
itemData.InodesUsed = result.state.InodesUsed
itemData.InodesFree = result.state.InodesFree
itemData.InodesUsedPercent = result.state.InodesUsedPercent
mu.Lock()
datas = append(datas, itemData)
mu.Unlock()
}
}(mounts[i]) }(mounts[i])
} }
wg.Wait() wg.Wait()
@@ -567,6 +534,69 @@ func loadDiskInfo() []dto.DiskInfo {
return datas return datas
} }
var diskMountsMu sync.Mutex
func loadDiskMounts() string {
if !diskMountsMu.TryLock() {
return ""
}
resultCh := make(chan string, 1)
go func() {
var stdout string
defer func() {
diskMountsMu.Unlock()
resultCh <- stdout
}()
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Second))
format := `NR>1 && !/tmpfs|snap\/core|udev/ {printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", $1, $2, $3, $4, $5, $6, $7}`
output, err := cmdMgr.RunPipe(
cmd.PipeCommand{Name: "df", Args: []string{"-hT", "-P"}},
cmd.PipeCommand{Name: "awk", Args: []string{format}},
)
if err != nil {
global.LOG.Errorf("load disk info with df -hT -P failed, err: %v", err)
cmdMgr2 := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second))
output, err = cmdMgr2.RunPipe(
cmd.PipeCommand{Name: "df", Args: []string{"-lhT", "-P"}},
cmd.PipeCommand{Name: "awk", Args: []string{format}},
)
if err != nil {
global.LOG.Errorf("load disk info with df -lhT -P failed, err: %v", err)
return
}
}
stdout = output
}()
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case stdout := <-resultCh:
return stdout
case <-timer.C:
global.LOG.Error("load disk mounts timed out; df collection is still running")
return ""
}
}
func loadDiskUsageWithTimeout(path string, forceRefresh bool) (*disk.UsageStat, error) {
type diskResult struct {
state *disk.UsageStat
err error
}
resultCh := make(chan diskResult, 1)
go func() {
state, err := psutil.DISK.GetUsage(path, forceRefresh)
resultCh <- diskResult{state: state, err: err}
}()
select {
case <-time.After(5 * time.Second):
return nil, fmt.Errorf("load disk usage from %s: timeout", path)
case result := <-resultCh:
return result.state, result.err
}
}
func loadAcceleratorInfo() ([]dto.GPUInfo, []dto.NPUInfo, []dto.XPUInfo) { func loadAcceleratorInfo() ([]dto.GPUInfo, []dto.NPUInfo, []dto.XPUInfo) {
ok, client := accelerator.New() ok, client := accelerator.New()
if !ok { if !ok {
@@ -586,6 +616,9 @@ func loadAcceleratorInfo() ([]dto.GPUInfo, []dto.NPUInfo, []dto.XPUInfo) {
xpuData []dto.XPUInfo xpuData []dto.XPUInfo
) )
for _, device := range snapshot.Devices { for _, device := range snapshot.Devices {
if device.ParentID != "" {
continue
}
switch device.Kind { switch device.Kind {
case accelerator.KindGPU: case accelerator.KindGPU:
if device.GPU == nil { if device.GPU == nil {
@@ -595,7 +628,11 @@ func loadAcceleratorInfo() ([]dto.GPUInfo, []dto.NPUInfo, []dto.XPUInfo) {
if err := copier.Copy(&dataItem, device.GPU); err != nil { if err := copier.Copy(&dataItem, device.GPU); err != nil {
continue continue
} }
dataItem.PowerUsage = dataItem.PowerDraw + " / " + dataItem.MaxPowerLimit dataItem.MaxPowerLimit = device.GPU.PowerLimit
dataItem.PowerUsage = dataItem.PowerDraw
if dataItem.MaxPowerLimit != "" {
dataItem.PowerUsage += " / " + dataItem.MaxPowerLimit
}
dataItem.MemoryUsage = dataItem.MemUsed + " / " + dataItem.MemTotal dataItem.MemoryUsage = dataItem.MemUsed + " / " + dataItem.MemTotal
gpuData = append(gpuData, dataItem) gpuData = append(gpuData, dataItem)
case accelerator.KindNPU: case accelerator.KindNPU:
@@ -672,6 +709,13 @@ func loadQuickJump(base *dto.DashboardBase) {
quicks := launcherRepo.ListQuickJump(false) quicks := launcherRepo.ListQuickJump(false)
for i := 0; i < len(quicks); i++ { for i := 0; i < len(quicks); i++ {
switch quicks[i].Name { switch quicks[i].Name {
case "File":
count, err := launcherRepo.CountQuickDirectories()
if err != nil {
global.LOG.Errorf("load quick directory count: %v", err)
}
quicks[i].Detail = fmt.Sprintf("%d", count)
quicks[i].Alias = ""
case "Agent": case "Agent":
quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber) quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber)
case "Website": case "Website":
+84 -5
View File
@@ -433,6 +433,10 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) {
} }
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) { func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
return u.create(ctx, req, false)
}
func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) { if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) {
return nil, buserr.New("ErrCmdIllegal") return nil, buserr.New("ErrCmdIllegal")
} }
@@ -470,13 +474,13 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
return nil, errors.New("cannot set root as user name") return nil, errors.New("cannot set root as user name")
} }
cli, version, err := LoadMysqlClientByFrom(req.Database) cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded)
if err != nil { if err != nil {
return nil, err return nil, err
} }
createItem.MysqlName = req.Database createItem.MysqlName = req.Database
defer cli.Close() defer cli.Close()
if err := cli.Create(client.CreateInfo{ createInfo := client.CreateInfo{
Name: req.Name, Name: req.Name,
Format: req.Format, Format: req.Format,
Collation: req.Collation, Collation: req.Collation,
@@ -485,9 +489,31 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
Permission: req.Permission, Permission: req.Permission,
Version: version, Version: version,
Timeout: 300, Timeout: 300,
}); err != nil { }
if mcpBounded {
local, ok := cli.(*client.Local)
if !ok {
return nil, errors.New("an installed local MySQL instance is required")
}
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, createInfo)
cancel()
} else {
err = cli.Create(createInfo)
}
if err != nil {
return nil, err return nil, err
} }
if mcpBounded {
if err := persistMCPMysqlMetadata(ctx, req, permissionHosts, &createItem); err != nil {
return nil, err
}
return &createItem, nil
}
if len(req.Username) != 0 { if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil { if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err return nil, err
@@ -506,6 +532,45 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
return &createItem, nil return &createItem, nil
} }
func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if req.Username != "" {
key := global.CONF.Base.EncryptKey
if key == "" {
var setting model.Setting
if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil {
return err
}
key = setting.Value
}
password, err := encrypt.StringEncryptWithKey(req.Password, key)
if err != nil {
return err
}
for _, host := range hosts {
var user model.DatabaseUser
err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
user.Type, user.Database, user.Username, user.Host = "mysql", req.Database, req.Username, host
user.Password, user.IsDelete = password, false
if req.Description != "" {
user.Description = req.Description
}
if err := tx.Save(&user).Error; err != nil {
return err
}
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
if err := tx.Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).FirstOrCreate(&grant).Error; err != nil {
return err
}
}
}
return tx.Create(item).Error
})
}
func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error) { func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error) {
dbType, err := resolveDatabaseUserType(req.Database) dbType, err := resolveDatabaseUserType(req.Database)
if err != nil { if err != nil {
@@ -1398,7 +1463,14 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS
return nil, err return nil, err
} }
statusMap, err := executeSqlForMaps(app.ContainerName, app.Key, app.Password, "show global status;") return loadMysqlStatus(req, app, executeSqlForMaps, executeSqlForRows)
}
func loadMysqlStatus(req dto.OperationWithNameAndType, app *repo.RootInfo,
maps func(string, string, string, string) (map[string]string, error),
rowsQuery func(string, string, string, string) ([]string, error),
) (*dto.MysqlStatus, error) {
statusMap, err := maps(app.ContainerName, app.Key, app.Password, "show global status;")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1426,7 +1498,7 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS
if common.CompareAppVersion(app.Version, "8.4.0") && (req.Type == constant.AppMysql || req.Type == constant.AppMysqlCluster) { if common.CompareAppVersion(app.Version, "8.4.0") && (req.Type == constant.AppMysql || req.Type == constant.AppMysqlCluster) {
masterStatus = "show binary log status;" masterStatus = "show binary log status;"
} }
rows, err := executeSqlForRows(app.ContainerName, app.Key, app.Password, masterStatus) rows, err := rowsQuery(app.ContainerName, app.Key, app.Password, masterStatus)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1531,6 +1603,10 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
} }
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) { func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
return loadMysqlClientByFrom(database, false)
}
func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) {
var ( var (
dbInfo client.DBInfo dbInfo client.DBInfo
version string version string
@@ -1542,6 +1618,9 @@ func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
return nil, "", errors.New("an installed local MySQL instance is required")
}
dbInfo.Type = databaseItem.Type dbInfo.Type = databaseItem.Type
dbInfo.From = databaseItem.From dbInfo.From = databaseItem.From
dbInfo.Database = database dbInfo.Database = database
+24
View File
@@ -0,0 +1,24 @@
package service
import (
"context"
"errors"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
)
func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
if req.From != "local" || ctx.Err() != nil {
return nil, errors.New("invalid MCP database target or request")
}
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
defer cancel()
created, err := u.create(writeContext, req, true)
if err != nil {
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
}
return created, nil
}
+63 -19
View File
@@ -8,6 +8,12 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
@@ -17,12 +23,15 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/compose" "github.com/1Panel-dev/1Panel/agent/utils/compose"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/docker/docker/api/types/container" dockerclient "github.com/docker/docker/client"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
) )
type RedisService struct{} type RedisService struct{}
// The CLI container is shared by all remote Redis databases on this node.
var redisCliInstallMutex sync.Mutex
type IRedisService interface { type IRedisService interface {
UpdateConf(req dto.RedisConfUpdate) error UpdateConf(req dto.RedisConfUpdate) error
UpdatePersistenceConf(req dto.RedisConfPersistenceUpdate) error UpdatePersistenceConf(req dto.RedisConfPersistenceUpdate) error
@@ -33,7 +42,7 @@ type IRedisService interface {
LoadPersistenceConf(req dto.LoadRedisStatus) (*dto.RedisPersistence, error) LoadPersistenceConf(req dto.LoadRedisStatus) (*dto.RedisPersistence, error)
CheckHasCli() bool CheckHasCli() bool
InstallCli() error InstallCli(req dto.RedisCliInstall) (string, error)
} }
func NewIRedisService() IRedisService { func NewIRedisService() IRedisService {
@@ -61,30 +70,65 @@ func (u *RedisService) UpdateConf(req dto.RedisConfUpdate) error {
} }
func (u *RedisService) CheckHasCli() bool { func (u *RedisService) CheckHasCli() bool {
client, err := docker.NewDockerClient() installed, _ := u.checkCliInstalled()
if err != nil { return installed
return false
}
defer client.Close()
containerLists, err := client.ContainerList(context.Background(), container.ListOptions{})
if err != nil {
return false
}
for _, item := range containerLists {
if strings.ReplaceAll(item.Names[0], "/", "") == "1Panel-redis-cli-tools" {
return true
}
}
return false
} }
func (u *RedisService) InstallCli() error { func (u *RedisService) checkCliInstalled() (bool, error) {
client, err := docker.NewDockerClient()
if err != nil {
return false, err
}
defer client.Close()
info, err := client.ContainerInspect(context.Background(), "1Panel-redis-cli-tools")
if dockerclient.IsErrNotFound(err) {
return false, nil
}
if err != nil {
return false, err
}
return info.ContainerJSONBase != nil && info.State != nil && info.State.Running, nil
}
func (u *RedisService) InstallCli(req dto.RedisCliInstall) (string, error) {
if !redisCliInstallMutex.TryLock() {
return "", buserr.New("TaskIsExecuting")
}
defer redisCliInstallMutex.Unlock()
item := dto.ContainerOperate{ item := dto.ContainerOperate{
TaskID: req.TaskID,
Name: "1Panel-redis-cli-tools", Name: "1Panel-redis-cli-tools",
Image: "redis:7.4.4", Image: "redis:7.4.4",
Networks: []dto.ContainerNetwork{{Network: "1panel-network"}}, Networks: []dto.ContainerNetwork{{Network: "1panel-network"}},
} }
return NewIContainerService().ContainerCreate(item, false) running, err := taskRepo.GetFirst(
repo.WithByName(task.GetTaskName(item.Name, task.TaskCreate, task.TaskScopeContainer)),
repo.WithByType(task.TaskScopeContainer),
taskRepo.WithByStatus(constant.StatusExecuting),
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
if running.ID != "" {
return running.ID, nil
}
installed, err := u.checkCliInstalled()
if err != nil || installed {
return "", err
}
if item.TaskID == "" {
item.TaskID = uuid.NewString()
}
if _, err := taskRepo.GetFirst(taskRepo.WithByID(item.TaskID)); !errors.Is(err, gorm.ErrRecordNotFound) {
if err != nil {
return "", err
}
return "", buserr.New("TaskIsExecuting")
}
if err := NewIContainerService().ContainerCreate(item, true); err != nil {
return "", err
}
return item.TaskID, nil
} }
func (u *RedisService) ChangePassword(req dto.ChangeRedisPass) error { func (u *RedisService) ChangePassword(req dto.ChangeRedisPass) error {
+1 -1
View File
@@ -73,7 +73,7 @@ func (u *DeviceService) LoadBaseInfo() (dto.DeviceBaseInfo, error) {
if baseInfo.SwapMemoryTotal != 0 { if baseInfo.SwapMemoryTotal != 0 {
baseInfo.SwapDetails = loadSwap() baseInfo.SwapDetails = loadSwap()
} }
disks := loadDiskInfo() disks := loadDiskInfo(false)
for _, item := range disks { for _, item := range disks {
baseInfo.MaxSize += item.Free baseInfo.MaxSize += item.Free
} }
+8 -3
View File
@@ -18,7 +18,8 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/controller" "github.com/1Panel-dev/1Panel/agent/utils/controller"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/docker_guard"
dockerfirewall "github.com/1Panel-dev/1Panel/agent/utils/firewall/docker_guard"
) )
const dockerNftablesMinVersion = "29.0.0" const dockerNftablesMinVersion = "29.0.0"
@@ -84,7 +85,7 @@ func (u *DockerService) UpdateFirewallBackend(backend string) error {
return fmt.Errorf("Docker Engine %s or later is required for the nftables firewall backend", dockerNftablesMinVersion) return fmt.Errorf("Docker Engine %s or later is required for the nftables firewall backend", dockerNftablesMinVersion)
} }
if backend == constant.FirewallProviderNftables { if backend == constant.FirewallProviderNftables {
if err := docker_guard.CheckIPv4Forwarding(); err != nil { if err := dockerfirewall.CheckIPv4Forwarding(); err != nil {
return err return err
} }
} }
@@ -282,7 +283,8 @@ func (u *DockerService) UpdateConf(req dto.SettingUpdate, withRestart bool) erro
delete(daemonMap, "ipv6") delete(daemonMap, "ipv6")
delete(daemonMap, "fixed-cidr-v6") delete(daemonMap, "fixed-cidr-v6")
delete(daemonMap, "ip6tables") delete(daemonMap, "ip6tables")
if configuredDockerFirewallBackend() != constant.FirewallProviderNftables { backend, _ := settingRepo.GetValueByKey(constant.FirewallDockerBackendKey)
if !strings.EqualFold(strings.TrimSpace(backend), constant.FirewallProviderNftables) {
delete(daemonMap, "experimental") delete(daemonMap, "experimental")
} }
} }
@@ -485,6 +487,9 @@ func (u *DockerService) OperateDocker(req dto.DockerOperation) error {
if err := controller.Handle(req.Operation, service); err != nil { if err := controller.Handle(req.Operation, service); err != nil {
return err return err
} }
if req.Operation == "start" || req.Operation == "restart" {
return RestoreDockerPortGuard(context.Background())
}
return nil return nil
} }
+1
View File
@@ -36,6 +36,7 @@ var (
ftpRepo = repo.NewIFtpRepo() ftpRepo = repo.NewIFtpRepo()
clamRepo = repo.NewIClamRepo() clamRepo = repo.NewIClamRepo()
monitorRepo = repo.NewIMonitorRepo() monitorRepo = repo.NewIMonitorRepo()
vllmMonitorRepo = &repo.VLLMMonitorRepo{}
settingRepo = repo.NewISettingRepo() settingRepo = repo.NewISettingRepo()
backupRepo = repo.NewIBackupRepo() backupRepo = repo.NewIBackupRepo()
+39 -10
View File
@@ -439,13 +439,15 @@ func (f *FileService) Compress(c request.FileCompress) error {
if err := preflightCompressTool(files.CompressType(c.Type)); err != nil { if err := preflightCompressTool(files.CompressType(c.Type)); err != nil {
return err return err
} }
taskItem, err := task.NewTask(c.Name, task.TaskExec, task.TaskScopeTask, c.TaskID, 1) taskName := i18n.GetMsgWithMap("FileTaskCompress", map[string]interface{}{"dst": strconv.Quote(filepath.Join(c.Dst, c.Name))})
taskItem, err := task.NewTask(taskName, task.TaskExec, task.TaskScopeTask, c.TaskID, 1)
if err != nil { if err != nil {
return err return err
} }
go func() { go func() {
taskItem.AddSubTask(c.Name, func(t *task.Task) error { taskItem.AddSubTask(taskName, func(t *task.Task) error {
t.LogStart(c.Name) logFileTaskSources(t, c.Files)
t.Log(i18n.GetMsgWithMap("FileTaskFormat", map[string]interface{}{"format": strconv.Quote(c.Type)}))
compressType := files.CompressType(c.Type) compressType := files.CompressType(c.Type)
dstFile := filepath.Join(c.Dst, c.Name) dstFile := filepath.Join(c.Dst, c.Name)
success := false success := false
@@ -516,13 +518,15 @@ func (f *FileService) DeCompress(c request.FileDeCompress) error {
if err := preflightDecompressTool(files.CompressType(c.Type)); err != nil { if err := preflightDecompressTool(files.CompressType(c.Type)); err != nil {
return err return err
} }
taskItem, err := task.NewTask(c.Path, task.TaskExec, task.TaskScopeTask, c.TaskID, 1) taskName := i18n.GetMsgWithMap("FileTaskDecompress", map[string]interface{}{"dst": strconv.Quote(c.Dst)})
taskItem, err := task.NewTask(taskName, task.TaskExec, task.TaskScopeTask, c.TaskID, 1)
if err != nil { if err != nil {
return err return err
} }
go func() { go func() {
taskItem.AddSubTask(c.Path, func(t *task.Task) error { taskItem.AddSubTask(taskName, func(t *task.Task) error {
t.LogStart(c.Path) logFileTaskSources(t, []string{c.Path})
t.Log(i18n.GetMsgWithMap("FileTaskFormat", map[string]interface{}{"format": strconv.Quote(c.Type)}))
dstExisted := fo.Stat(c.Dst) dstExisted := fo.Stat(c.Dst)
parentDir := filepath.Dir(c.Dst) parentDir := filepath.Dir(c.Dst)
if !fo.Stat(parentDir) { if !fo.Stat(parentDir) {
@@ -878,6 +882,16 @@ func (f *FileService) ChangeName(req request.FileRename) error {
if hasInvalidFileName(req.NewName) { if hasInvalidFileName(req.NewName) {
return buserr.New("ErrInvalidChar") return buserr.New("ErrInvalidChar")
} }
if req.OldName == req.NewName {
return nil
}
// os.Rename silently replaces an existing target on Linux, so reject the
// rename here to avoid losing the target file without confirmation.
if _, err := os.Lstat(req.NewName); err == nil {
return buserr.New("ErrFileIsExist")
} else if !os.IsNotExist(err) {
return err
}
fo := files.NewFileOp() fo := files.NewFileOp()
content, mode, shouldRecordHistory := readEditableFileHistoryContent(req.OldName) content, mode, shouldRecordHistory := readEditableFileHistoryContent(req.OldName)
if err := fo.Rename(req.OldName, req.NewName); err != nil { if err := fo.Rename(req.OldName, req.NewName); err != nil {
@@ -914,6 +928,12 @@ func (f *FileService) Wget(w request.FileWget) (string, error) {
return key, fo.DownloadFileWithProcess(w.Url, filepath.Join(w.Path, w.Name), key, options) return key, fo.DownloadFileWithProcess(w.Url, filepath.Join(w.Path, w.Name), key, options)
} }
func logFileTaskSources(t *task.Task, sources []string) {
for _, source := range sources {
t.Log(i18n.GetMsgWithMap("FileTaskSource", map[string]interface{}{"path": strconv.Quote(source)}))
}
}
func (f *FileService) MvFile(m request.FileMove) error { func (f *FileService) MvFile(m request.FileMove) error {
fo := files.NewFileOp() fo := files.NewFileOp()
if err := validateFileMove(fo, m); err != nil { if err := validateFileMove(fo, m); err != nil {
@@ -925,15 +945,24 @@ func (f *FileService) MvFile(m request.FileMove) error {
if !fileTransferLocks.Acquire(m.TaskID, getFileTransferPaths(m)) { if !fileTransferLocks.Acquire(m.TaskID, getFileTransferPaths(m)) {
return buserr.New("TaskIsExecuting") return buserr.New("TaskIsExecuting")
} }
taskItem, err := task.NewTask(m.NewPath, task.TaskExec, task.TaskScopeTask, m.TaskID, 1) nameKey := "FileTaskCopy"
if m.Type == "cut" {
nameKey = "FileTaskMove"
}
taskName := i18n.GetMsgWithMap(nameKey, map[string]interface{}{"dst": strconv.Quote(m.NewPath)})
taskItem, err := task.NewTask(taskName, task.TaskExec, task.TaskScopeTask, m.TaskID, 1)
if err != nil { if err != nil {
fileTransferLocks.Release(m.TaskID) fileTransferLocks.Release(m.TaskID)
return err return err
} }
go func() { go func() {
defer fileTransferLocks.Release(m.TaskID) defer fileTransferLocks.Release(m.TaskID)
taskItem.AddSubTaskWithOps(m.NewPath, func(t *task.Task) error { taskItem.AddSubTaskWithOps(taskName, func(t *task.Task) error {
t.LogStart(m.NewPath) logFileTaskSources(t, m.OldPaths)
logFileTaskSources(t, m.CoverPaths)
if m.Name != "" {
t.Log(i18n.GetMsgWithMap("FileTaskRename", map[string]interface{}{"name": strconv.Quote(m.Name)}))
}
err := f.moveFileWithContext(t.TaskCtx, m) err := f.moveFileWithContext(t.TaskCtx, m)
if err != nil && t.TaskCtx.Err() != nil { if err != nil && t.TaskCtx.Err() != nil {
return t.TaskCtx.Err() return t.TaskCtx.Err()
@@ -1291,7 +1320,7 @@ func (f *FileService) BatchCheckFiles(req request.FilePathsCheck) []response.Exi
} }
func (f *FileService) GetHostMount() []dto.DiskInfo { func (f *FileService) GetHostMount() []dto.DiskInfo {
return loadDiskInfo() return loadDiskInfo(false)
} }
func (f *FileService) GetUsersAndGroups() (*response.UserGroupResponse, error) { func (f *FileService) GetUsersAndGroups() (*response.UserGroupResponse, error) {
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
)
func TestChangeNameRejectsExistingTarget(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old.txt")
targetPath := filepath.Join(dir, "target.txt")
// Binary source content keeps this test independent of the file-history DB.
if err := os.WriteFile(oldPath, []byte("old\x00"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(targetPath, []byte("target"), 0644); err != nil {
t.Fatal(err)
}
err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: targetPath})
if err == nil {
t.Fatal("expected an error when the target name already exists")
}
var bizErr buserr.BusinessError
if !errors.As(err, &bizErr) || bizErr.Msg != "ErrFileIsExist" {
t.Fatalf("expected ErrFileIsExist, got %v", err)
}
content, err := os.ReadFile(targetPath)
if err != nil {
t.Fatal(err)
}
if string(content) != "target" {
t.Fatalf("target file was overwritten, content = %q", content)
}
if _, err := os.Stat(oldPath); err != nil {
t.Fatalf("source file should still exist after a rejected rename: %v", err)
}
}
func TestChangeNameRenamesToNewName(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old")
newPath := filepath.Join(dir, "new")
if err := os.Mkdir(oldPath, 0755); err != nil {
t.Fatal(err)
}
if err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: newPath}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(newPath); err != nil {
t.Fatalf("renamed path does not exist: %v", err)
}
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("old path should be gone, stat err = %v", err)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-76
View File
@@ -1,76 +0,0 @@
package service
import (
"context"
"fmt"
"io"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
)
const (
firewallTaskHost = "FirewallTaskHost"
firewallTaskForwarding = "FirewallTaskForwarding"
firewallTaskDocker = "FirewallTaskDocker"
)
func firewallTaskName(operation, subsystem, backend string) string {
name := i18n.GetMsgByKey(subsystem)
if backend != "" {
name += " · " + backend
}
key := "FirewallRule" + operation
if operation == task.TaskExec {
key = "FirewallTaskInitialize"
}
return i18n.GetMsgWithMap(key, map[string]interface{}{"name": name})
}
func queueFirewallRuleTask(subsystem, operation string, labels []string, apply func(context.Context) error) (dto.FilterChainOperationResponse, error) {
taskItem, err := task.NewTask(firewallTaskName(operation, subsystem, ""), operation, task.TaskScopeFirewall, "", 0)
if err != nil {
return dto.FilterChainOperationResponse{}, err
}
taskItem.AddSubTaskWithOps(taskItem.Name, func(t *task.Task) error {
t.Logf("rules=%d", len(labels))
err := t.TaskCtx.Err()
if err == nil {
err = apply(t.TaskCtx)
}
succeeded, failed := 0, 0
for _, label := range labels {
if err != nil {
failed++
t.LogFailedWithErr(label, err)
} else {
succeeded++
t.LogSuccess(label)
}
}
t.Log(i18n.GetMsgWithMap("FirewallRuleOperationResult", map[string]interface{}{
"succeeded": succeeded, "failed": failed,
}))
return err
}, nil, 0, 0)
if err := repo.NewITaskRepo().Save(context.Background(), taskItem.Task); err != nil {
taskItem.LogFailedWithErr(taskItem.Name, err)
closeUnstartedFirewallTask(taskItem)
return dto.FilterChainOperationResponse{}, fmt.Errorf("save firewall rule task: %w", err)
}
go func() { _ = taskItem.Execute() }()
return dto.FilterChainOperationResponse{TaskID: taskItem.TaskID, Queued: true}, nil
}
func closeUnstartedFirewallTask(t *task.Task) {
if cancel, ok := global.LoadTaskCancel(t.TaskID); ok {
cancel()
}
global.RemoveTaskCancel(t.TaskID)
if closer, ok := t.Logger.Out.(io.Closer); ok {
_ = closer.Close()
}
}
-65
View File
@@ -1,65 +0,0 @@
package service
import (
"strings"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/lifecycle"
)
func selectedDockerFirewallBackend(fallback string) string {
selected := configuredDockerFirewallBackend()
if selected == constant.FirewallProviderIptables || selected == constant.FirewallProviderNftables {
return selected
}
fallback = strings.ToLower(strings.TrimSpace(fallback))
if fallback == constant.FirewallProviderNftables {
return fallback
}
return constant.FirewallProviderIptables
}
func configuredDockerFirewallBackend() string {
if global.DB == nil {
return ""
}
selected, _ := settingRepo.GetValueByKey(constant.FirewallDockerBackendKey)
selected = strings.ToLower(strings.TrimSpace(selected))
if selected == constant.FirewallProviderIptables || selected == constant.FirewallProviderNftables {
return selected
}
return ""
}
func selectedSystemFirewallClient() (lifecycle.Client, error) {
if provider := configuredSystemFirewallBackend(); provider != "" {
return lifecycle.NewClientFor(provider)
}
client, err := lifecycle.NewClient()
if err != nil {
return nil, err
}
_ = settingRepo.UpdateOrCreate(constant.FirewallSystemBackendKey, client.Name())
return client, nil
}
func configuredSystemFirewallBackend() string {
if global.DB == nil {
return ""
}
provider, _ := settingRepo.GetValueByKey(constant.FirewallSystemBackendKey)
return strings.TrimSpace(provider)
}
func NewSelectedSystemFirewallClient() (lifecycle.Client, error) {
return selectedSystemFirewallClient()
}
func selectedSystemFirewallProvider() (string, error) {
client, err := selectedSystemFirewallClient()
if err != nil {
return "", err
}
return client.Name(), nil
}
+383 -332
View File
@@ -5,20 +5,20 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"reflect"
"slices" "slices"
"strings"
"sync" "sync"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/firewall" "github.com/1Panel-dev/1Panel/agent/utils/firewall"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/docker_guard" dockerfirewall "github.com/1Panel-dev/1Panel/agent/utils/firewall/docker_guard"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter" "github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
filterruntime "github.com/1Panel-dev/1Panel/agent/utils/firewall/filter/runtime" "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/iptables_helper" "github.com/1Panel-dev/1Panel/agent/utils/firewall/iptables_helper"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/lifecycle" "github.com/1Panel-dev/1Panel/agent/utils/firewall/lifecycle"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/nftables_helper" "github.com/1Panel-dev/1Panel/agent/utils/firewall/nftables_helper"
@@ -31,211 +31,124 @@ type IFirewallSettingService interface {
DeletePortWhitelist(context.Context, dto.FirewallPortWhitelistDelete) error DeletePortWhitelist(context.Context, dto.FirewallPortWhitelistDelete) error
Load(context.Context) (dto.FirewallSettings, error) Load(context.Context) (dto.FirewallSettings, error)
Operate(context.Context, dto.FirewallBackendOperation) error Operate(context.Context, dto.FirewallBackendOperation) error
OperateFamily(dto.FirewallFamilyOperation) (dto.FilterChainOperationResponse, error)
OperateIPv6(dto.FirewallIPv6Operation) (dto.FilterChainOperationResponse, error)
} }
type FirewallSettingService struct{} type FirewallSettingService struct{}
var firewallWhitelistMu sync.Mutex var firewallWhitelistMu sync.Mutex
var ErrFirewallBackendCleanupRequired = errors.New("firewall backend cleanup required") func (s *FirewallSettingService) CreatePortWhitelist(ctx context.Context, request dto.FirewallPortWhitelistCreate) (result error) {
firewallWhitelistMu.Lock()
func firewallBackendCleanupRequired(current, target string) error { firewallRuleMutationMu.Lock()
return fmt.Errorf( defer func() {
"%w: current backend %s still contains 1Panel runtime rules; clean it up before switching to %s", firewallRuleMutationMu.Unlock()
ErrFirewallBackendCleanupRequired, firewallWhitelistMu.Unlock()
current, if result == nil {
target, result = newFirewallService().SyncPortWhitelist(ctx)
) }
}()
current, err := loadFirewallPortWhiteList()
if err != nil {
return err
}
current = append(current, request.Rule)
current, err = firewall.ValidatePortWhitelist(current)
if err != nil {
return err
}
if err := validateFirewallWhitelistFamilies(current); err != nil {
return err
}
value, err := json.Marshal(current)
if err != nil {
return err
}
return settingRepo.UpdateOrCreate(constant.FirewallPortWhiteList, string(value))
} }
func NewIFirewallSettingService() IFirewallSettingService { func (s *FirewallSettingService) UpdatePortWhitelist(ctx context.Context, request dto.FirewallPortWhitelistUpdate) (result error) {
return &FirewallSettingService{} firewallWhitelistMu.Lock()
} firewallRuleMutationMu.Lock()
defer func() {
func (s *FirewallSettingService) CreatePortWhitelist(ctx context.Context, request dto.FirewallPortWhitelistCreate) error { firewallRuleMutationMu.Unlock()
return savePortWhitelist(ctx, func(current []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) { firewallWhitelistMu.Unlock()
return append(current, request.Rule), nil if result == nil {
}) result = newFirewallService().SyncPortWhitelist(ctx)
} }
}()
func (s *FirewallSettingService) UpdatePortWhitelist(ctx context.Context, request dto.FirewallPortWhitelistUpdate) error { current, err := loadFirewallPortWhiteList()
return savePortWhitelist(ctx, func(current []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) { if err != nil {
return err
}
index, err := findPortWhitelistRule(current, request.OldRule) index, err := findPortWhitelistRule(current, request.OldRule)
if err != nil { if err != nil {
return nil, err return err
} }
current[index] = request.Rule current[index] = request.Rule
return current, nil current, err = firewall.ValidatePortWhitelist(current)
}) if err != nil {
return err
}
if err := validateFirewallWhitelistFamilies(current); err != nil {
return err
}
value, err := json.Marshal(current)
if err != nil {
return err
}
return settingRepo.UpdateOrCreate(constant.FirewallPortWhiteList, string(value))
} }
func (s *FirewallSettingService) DeletePortWhitelist(ctx context.Context, request dto.FirewallPortWhitelistDelete) error { func (s *FirewallSettingService) DeletePortWhitelist(ctx context.Context, request dto.FirewallPortWhitelistDelete) error {
firewallWhitelistMu.Lock()
firewallRuleMutationMu.Lock()
defer func() {
firewallRuleMutationMu.Unlock()
firewallWhitelistMu.Unlock()
}()
current, err := loadFirewallPortWhiteList()
if err != nil {
return err
}
if request.Rule == nil { if request.Rule == nil {
return fmt.Errorf("select one firewall port whitelist rule to delete") return filter.ErrInvalidRule
} }
return savePortWhitelist(ctx, func(current []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) {
index, err := findPortWhitelistRule(current, *request.Rule) index, err := findPortWhitelistRule(current, *request.Rule)
if err != nil { if err != nil {
return nil, err return err
} }
return slices.Delete(current, index, index+1), nil current = slices.Delete(current, index, index+1)
}) current, err = firewall.ValidatePortWhitelist(current)
}
func findPortWhitelistRule(rules []firewall.PortWhitelist, target firewall.PortWhitelist) (int, error) {
index := slices.IndexFunc(rules, func(rule firewall.PortWhitelist) bool {
return samePortWhitelistRule(rule, target)
})
if index < 0 {
return -1, fmt.Errorf("firewall port whitelist rule has changed or no longer exists; refresh and retry")
}
return index, nil
}
func samePortWhitelistRule(left, right firewall.PortWhitelist) bool {
if reflect.DeepEqual(left, right) {
return true
}
normalizedLeft, err := firewall.ValidatePortWhitelist([]firewall.PortWhitelist{left})
if err != nil {
return false
}
normalizedRight, err := firewall.ValidatePortWhitelist([]firewall.PortWhitelist{right})
if err != nil {
return false
}
slices.Sort(normalizedLeft[0].Sources)
slices.Sort(normalizedRight[0].Sources)
return reflect.DeepEqual(normalizedLeft[0], normalizedRight[0])
}
func savePortWhitelist(ctx context.Context, change func([]firewall.PortWhitelist) ([]firewall.PortWhitelist, error)) error {
firewallWhitelistMu.Lock()
defer firewallWhitelistMu.Unlock()
firewallRuleMutationMu.Lock()
defer firewallRuleMutationMu.Unlock()
defer filterruntime.InvalidateInventory()
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
current, err := loadPortWhitelistSetting(tx)
if err != nil { if err != nil {
return err return err
} }
desired, err := change(current) if err := validateFirewallWhitelistFamilies(current); err != nil {
return err
}
value, err := json.Marshal(current)
if err != nil { if err != nil {
return err return err
} }
desired, err = firewall.ValidatePortWhitelist(desired) return settingRepo.UpdateOrCreate(constant.FirewallPortWhiteList, string(value))
if err != nil {
return err
}
value, err := json.Marshal(desired)
if err != nil {
return err
}
return tx.Where("key = ?", constant.FirewallPortWhiteList).Assign(map[string]interface{}{"value": string(value)}).
FirstOrCreate(&model.Setting{Key: constant.FirewallPortWhiteList}).Error
})
}
func checkFirewallRuleWhitelistProtection(provider filter.Provider, record model.FirewallRule) error {
ports, err := loadFirewallPortWhiteList()
if err != nil {
return err
}
rules, err := record.RulesForProvider(provider)
if err != nil {
return err
}
for _, rule := range rules {
if filter.RuleMatchesPortWhitelist(rule, ports) {
return filter.ErrProtectedRule
}
}
return nil
}
func loadPortWhitelistSetting(db *gorm.DB) ([]firewall.PortWhitelist, error) {
var setting model.Setting
if err := db.Where("key = ?", constant.FirewallPortWhiteList).First(&setting).Error; errors.Is(err, gorm.ErrRecordNotFound) {
setting.Value = constant.FirewallPortWhiteListValue
} else if err != nil {
return nil, err
}
var rules []firewall.PortWhitelist
err := json.Unmarshal([]byte(setting.Value), &rules)
return rules, err
}
func loadSSHWhitelistPortFrom(path string) (string, error) {
directives, _, err := parseSSHConfigTree(path)
if errors.Is(err, os.ErrNotExist) {
return defaultSSHPort, nil
}
if err != nil {
return "", err
}
return loadSSHPortValues(directives)[0], nil
}
func customWhitelist(entries []firewall.PortWhitelist) []firewall.PortWhitelist {
result := make([]firewall.PortWhitelist, 0, len(entries))
for _, entry := range entries {
if entry.Type == "" {
result = append(result, entry)
}
}
return result
}
func InitializeFirewallWhitelistPorts(entries []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) {
entries = slices.Clone(entries)
var sshPort string
for i := range entries {
entry := &entries[i]
if entry.Type == "" || entry.Port != "" {
continue
}
switch entry.Type {
case firewall.PortWhitelistTypePanel:
entry.Port = LoadPanelPort()
case firewall.PortWhitelistTypeSSH:
if sshPort == "" {
var err error
sshPort, err = loadSSHWhitelistPortFrom(sshPath)
if err != nil {
return nil, err
}
}
entry.Port = sshPort
}
}
return firewall.ValidatePortWhitelist(entries)
}
func updateSystemAccessPortWhitelist(ctx context.Context, serviceType string, ports []string) error {
return savePortWhitelist(ctx, func(entries []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) {
for i := range entries {
if entries[i].Type == serviceType {
if len(ports) == 0 {
return nil, fmt.Errorf("firewall whitelist %s requires a port", serviceType)
}
entries[i].Port = ports[0]
}
}
return entries, nil
})
} }
func (s *FirewallSettingService) Load(ctx context.Context) (dto.FirewallSettings, error) { func (s *FirewallSettingService) Load(ctx context.Context) (dto.FirewallSettings, error) {
result := dto.FirewallSettings{PingStatus: firewall.LoadPingStatus()} families, err := loadFirewallFamilies()
if err != nil {
return dto.FirewallSettings{}, err
}
result := dto.FirewallSettings{PingStatus: firewall.LoadPingStatus(), IPv6Enabled: slices.Contains(families, constant.FirewallFamilyIPv6)}
installed := make(map[string]bool) installed := make(map[string]bool)
for _, name := range lifecycle.InstalledProviders() { for _, name := range lifecycle.InstalledProviders() {
installed[name] = true installed[name] = true
} }
result.System.Selected = configuredSystemFirewallBackend() systemBackend, _ := settingRepo.GetValueByKey(constant.FirewallSystemBackendKey)
result.System.Selected = strings.TrimSpace(systemBackend)
if result.System.Selected == "" { if result.System.Selected == "" {
if client, err := lifecycle.NewClient(); err == nil { if client, err := lifecycle.NewClient(""); err == nil {
result.System.Selected = client.Name() result.System.Selected = client.Name()
} }
} }
@@ -248,16 +161,16 @@ func (s *FirewallSettingService) Load(ctx context.Context) (dto.FirewallSettings
} { } {
option := dto.FirewallBackendOption{Name: name, Installed: installed[name], Supported: true} option := dto.FirewallBackendOption{Name: name, Installed: installed[name], Supported: true}
if option.Installed && name == result.System.Selected { if option.Installed && name == result.System.Selected {
client, err := lifecycle.NewClientFor(name) client, err := lifecycle.NewClient(name)
if err != nil { if err != nil {
option.Message = err.Error() option.Message = err.Error()
} else if supportsManagedFilterChains(name) { } else if name == constant.FirewallProviderIptables || name == constant.FirewallProviderNftables {
option.Initialized, option.Bound, err = loadFirewallInitStatus(name, "base") overview, err := loadSystemFirewallOverview(name, "base", families)
if err != nil { if err != nil {
option.Message = err.Error() option.Message = err.Error()
} }
option.IPv4 = loadSystemFirewallFamilyInfo(name, constant.FirewallFamilyIPv4) option.Initialized, option.Bound = overview.IsInit, overview.IsBind
option.IPv6 = loadSystemFirewallFamilyInfo(name, constant.FirewallFamilyIPv6) option.IPv4, option.IPv6 = overview.IPv4, overview.IPv6
} else if option.Active, err = client.Status(); err != nil { } else if option.Active, err = client.Status(); err != nil {
option.Message = err.Error() option.Message = err.Error()
} }
@@ -270,32 +183,29 @@ func (s *FirewallSettingService) Load(ctx context.Context) (dto.FirewallSettings
result.System.Options = append(result.System.Options, option) result.System.Options = append(result.System.Options, option)
} }
result.Forwarding.Selected = configuredForwardingBackend() forwardingBackend, _ := settingRepo.GetValueByKey(constant.FirewallForwardingBackendKey)
result.Forwarding.Selected = strings.TrimSpace(forwardingBackend)
if result.Forwarding.Selected == "" {
result.Forwarding.Selected = constant.FirewallProviderIptables
}
result.Forwarding.Current = result.Forwarding.Selected result.Forwarding.Current = result.Forwarding.Selected
for _, name := range []string{constant.FirewallProviderIptables, constant.FirewallProviderNftables} { for _, name := range []string{constant.FirewallProviderIptables, constant.FirewallProviderNftables} {
option := dto.FirewallBackendOption{Name: name, Installed: installed[name], Supported: true} option := dto.FirewallBackendOption{Name: name, Installed: installed[name], Supported: true}
if option.Installed && name == result.Forwarding.Selected { if option.Installed && name == result.Forwarding.Selected {
manager, err := newForwardingManagerFor(name) manager, err := newForwardingAdapterFor(ctx, name)
if err != nil { if err != nil {
option.Message = err.Error() option.Message = err.Error()
} else if status, err := manager.Status(); err != nil { } else {
option.Message = err.Error() status, statusErr := loadForwardingFirewallOverview(manager, families)
option.IPv4, option.IPv6 = status.IPv4, status.IPv6
if statusErr != nil {
option.Message = statusErr.Error()
} else { } else {
option.Initialized, option.Bound = status.IsInit, status.IsBind option.Initialized, option.Bound = status.IsInit, status.IsBind
ipv4Init, ipv4Bound, ipv4Err := manager.FamilyStatus(constant.FirewallFamilyIPv4)
ipv6Init, ipv6Bound, ipv6Err := manager.FamilyStatus(constant.FirewallFamilyIPv6)
option.IPv4 = dto.FirewallBackendFamilyStatus{
Available: ipv4Err == nil, Initialized: ipv4Init, Bound: ipv4Bound,
}
option.IPv6 = dto.FirewallBackendFamilyStatus{
Available: ipv6Err == nil, Initialized: ipv6Init, Bound: ipv6Bound,
}
if name == constant.FirewallProviderIptables {
if commands, commandErr := lifecycle.ResolveIptablesCommands(); commandErr == nil {
option.IPv6.Available = option.IPv6.Available && commands.IPv6Available()
if !commands.IPv6Available() {
option.IPv6.Reason = docker_guard.ReasonCommandMissing
} }
if result.IPv6Enabled && name == constant.FirewallProviderIptables && !option.IPv6.Available {
if commands, err := lifecycle.ResolveIptablesCommands(); err == nil && !commands.IPv6Available() {
option.IPv6.Reason = dockerfirewall.ReasonCommandMissing
} }
} }
} }
@@ -313,7 +223,11 @@ func (s *FirewallSettingService) Load(ctx context.Context) (dto.FirewallSettings
if dockerInstalled { if dockerInstalled {
dockerVersion = loadDockerEngineVersion(ctx) dockerVersion = loadDockerEngineVersion(ctx)
} }
result.Docker.Selected = configuredDockerFirewallBackend() dockerBackend, _ := settingRepo.GetValueByKey(constant.FirewallDockerBackendKey)
dockerBackend = strings.ToLower(strings.TrimSpace(dockerBackend))
if dockerBackend == constant.FirewallProviderIptables || dockerBackend == constant.FirewallProviderNftables {
result.Docker.Selected = dockerBackend
}
result.Docker.Current = result.Docker.Selected result.Docker.Current = result.Docker.Selected
for _, name := range []string{constant.FirewallProviderIptables, constant.FirewallProviderNftables} { for _, name := range []string{constant.FirewallProviderIptables, constant.FirewallProviderNftables} {
option := dto.FirewallBackendOption{ option := dto.FirewallBackendOption{
@@ -326,20 +240,25 @@ func (s *FirewallSettingService) Load(ctx context.Context) (dto.FirewallSettings
option.Active = false option.Active = false
} }
if option.Active { if option.Active {
guard := docker_guard.NewRuntime(name) guard := newDockerFirewallRuntime(ctx, name)
ipv4, ipv6 := guard.Status(docker_guard.FamilyIPv4), guard.Status(docker_guard.FamilyIPv6) for _, family := range families {
option.Initialized = ipv4.Initialized || ipv6.Initialized status := guard.Status(family)
option.Bound = ipv4.Bound || ipv6.Bound option.Initialized = option.Initialized || status.Initialized
option.IPv4.Initialized, option.IPv4.Bound = ipv4.Initialized, ipv4.Bound option.Bound = option.Bound || status.Bound
option.IPv6.Initialized, option.IPv6.Bound = ipv6.Initialized, ipv6.Bound info := dto.FirewallBackendFamilyStatus{
option.IPv4.Available = ipv4.Reason != docker_guard.ReasonCommandMissing Available: status.Reason != dockerfirewall.ReasonCommandMissing,
option.IPv6.Available = ipv6.Reason != docker_guard.ReasonCommandMissing Initialized: status.Initialized, Bound: status.Bound, Reason: status.Reason,
option.IPv4.Reason, option.IPv6.Reason = ipv4.Reason, ipv6.Reason }
if family == constant.FirewallFamilyIPv4 {
option.IPv4 = info
} else {
option.IPv6 = info
}
}
} }
result.Docker.Options = append(result.Docker.Options, option) result.Docker.Options = append(result.Docker.Options, option)
} }
var err error result.PortWhitelist, err = loadPortWhitelistSetting()
result.PortWhitelist, err = loadPortWhitelistSetting(global.DB.WithContext(ctx))
if err != nil { if err != nil {
return result, err return result, err
} }
@@ -353,33 +272,11 @@ func (s *FirewallSettingService) Load(ctx context.Context) (dto.FirewallSettings
return result, err return result, err
} }
func loadSystemFirewallFamilyStatus(provider, family string) (bool, bool, error) {
switch provider {
case constant.FirewallProviderIptables:
return iptables_helper.LoadFamilyInitStatus(family, "base")
case constant.FirewallProviderNftables:
return nftables_helper.LoadFamilyInitStatus(filter.Family(family), "base")
default:
return false, false, fmt.Errorf("unsupported firewall provider %q", provider)
}
}
func loadSystemFirewallFamilyInfo(provider, family string) dto.FirewallBackendFamilyStatus {
if provider == constant.FirewallProviderIptables && family == constant.FirewallFamilyIPv6 {
commands, err := lifecycle.ResolveIptablesCommands()
if err != nil || !commands.IPv6Available() {
return dto.FirewallBackendFamilyStatus{Reason: docker_guard.ReasonCommandMissing}
}
}
initialized, bound, err := loadSystemFirewallFamilyStatus(provider, family)
return dto.FirewallBackendFamilyStatus{
Available: err == nil,
Initialized: initialized,
Bound: bound,
}
}
func (s *FirewallSettingService) Operate(ctx context.Context, request dto.FirewallBackendOperation) error { func (s *FirewallSettingService) Operate(ctx context.Context, request dto.FirewallBackendOperation) error {
if request.Operation == "cleanup" {
_, err := newFirewallService().Reset(ctx, dto.FirewallRuleReset{Subsystem: request.Subsystem, Provider: filter.Provider(request.Backend)})
return err
}
if err := lockFirewallLifecycleIdle(); err != nil { if err := lockFirewallLifecycleIdle(); err != nil {
return err return err
} }
@@ -387,7 +284,7 @@ func (s *FirewallSettingService) Operate(ctx context.Context, request dto.Firewa
if request.Subsystem != "system" && request.Backend != constant.FirewallProviderIptables && request.Backend != constant.FirewallProviderNftables { if request.Subsystem != "system" && request.Backend != constant.FirewallProviderIptables && request.Backend != constant.FirewallProviderNftables {
return fmt.Errorf("%s only supports iptables or nftables", request.Subsystem) return fmt.Errorf("%s only supports iptables or nftables", request.Subsystem)
} }
if request.Subsystem == "system" && !supportsManagedFilterChains(request.Backend) && request.Operation != "select" { if request.Subsystem == "system" && (request.Backend != constant.FirewallProviderIptables && request.Backend != constant.FirewallProviderNftables) && request.Operation != "select" {
return fmt.Errorf("%s does not support initialization or cleanup", request.Backend) return fmt.Errorf("%s does not support initialization or cleanup", request.Backend)
} }
switch request.Subsystem { switch request.Subsystem {
@@ -397,13 +294,12 @@ func (s *FirewallSettingService) Operate(ctx context.Context, request dto.Firewa
} }
if request.Operation == "initialize" { if request.Operation == "initialize" {
service := newFirewallService() service := newFirewallService()
rulesErr := service.restoreStoredFirewallRules(ctx, filter.Provider(request.Backend), nil)
whitelistErr := service.SyncPortWhitelist(ctx) whitelistErr := service.SyncPortWhitelist(ctx)
return errors.Join(rulesErr, whitelistErr) return whitelistErr
} }
return nil return nil
case "forwarding": case "forwarding":
return s.operateForwarding(request) return s.operateForwarding(ctx, request)
case "docker": case "docker":
return s.operateDocker(ctx, request) return s.operateDocker(ctx, request)
default: default:
@@ -411,72 +307,224 @@ func (s *FirewallSettingService) Operate(ctx context.Context, request dto.Firewa
} }
} }
func (s *FirewallSettingService) operateDocker(ctx context.Context, request dto.FirewallBackendOperation) error { func (s *FirewallSettingService) OperateFamily(request dto.FirewallFamilyOperation) (dto.FilterChainOperationResponse, error) {
guard := docker_guard.NewRuntime(request.Backend) if request.Family != constant.FirewallFamilyIPv4 && request.Family != constant.FirewallFamilyIPv6 {
if request.Operation == "cleanup" { return dto.FilterChainOperationResponse{}, filter.ErrInvalidScope
if err := guard.Cleanup(); err != nil { }
if request.Backend != constant.FirewallProviderIptables && request.Backend != constant.FirewallProviderNftables {
return dto.FilterChainOperationResponse{}, filter.ErrUnsupportedScope
}
if request.Operation != "initialize" && request.Operation != "repair" && request.Operation != "bind" {
return dto.FilterChainOperationResponse{}, filter.ErrRuleOperation
}
subsystem := ""
switch request.Subsystem {
case "system":
subsystem = firewallTaskHost
case "forwarding":
subsystem = firewallTaskForwarding
case "docker":
subsystem = firewallTaskDocker
default:
return dto.FilterChainOperationResponse{}, filter.ErrInvalidScope
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeFirewall, 0); err != nil {
return dto.FilterChainOperationResponse{}, err
}
return queueFirewallRuleTask(subsystem, task.TaskExec, "", nil, func(t *task.Task) error {
if err := lockFirewallLifecycleIdle(); err != nil {
return err return err
} }
return settingRepo.UpdateOrCreate(constant.FirewallDockerPortGuardStatusKey, constant.StatusDisable) defer firewallLifecycleTaskMu.Unlock()
} t.Logf("backend=%s family=%s operation=%s", request.Backend, request.Family, request.Operation)
previous, _ := settingRepo.GetValueByKey(constant.FirewallDockerBackendKey) families, err := loadFirewallFamilies()
if request.Operation == "select" {
current := previous
if current == "" {
current = alternateDirectBackend(request.Backend)
}
initialized, err := dockerGuardBackendInitialized(current)
if err != nil { if err != nil {
return err return err
} }
if current != request.Backend && initialized { if !slices.Contains(families, request.Family) {
return firewallBackendCleanupRequired(current, request.Backend) return fmt.Errorf("IPv6 firewall support is disabled")
} }
} initialize := request.Operation != "bind"
if err := settingRepo.UpdateOrCreate(constant.FirewallDockerBackendKey, request.Backend); err != nil { switch request.Subsystem {
case "system":
firewallWhitelistMu.Lock()
defer firewallWhitelistMu.Unlock()
if err := newFirewallService().checkSelectedProvider(t.TaskCtx, filter.Provider(request.Backend)); err != nil {
return err return err
} }
if request.Operation == "select" { ports, err := loadFirewallPortWhiteList()
if err := (&DockerService{}).UpdateFirewallBackend(request.Backend); err != nil { if err != nil {
_ = settingRepo.UpdateOrCreate(constant.FirewallDockerBackendKey, previous) return err
}
required, err := firewall.RequiredPortWhitelist(ports)
if err != nil {
return err
}
firewallRuleMutationMu.Lock()
if request.Backend == constant.FirewallProviderIptables {
err = iptables_helper.OperateFamily(request.Family, initialize, required)
} else {
err = nftables_helper.OperateFamily(filter.Family(request.Family), initialize, required)
}
firewallRuleMutationMu.Unlock()
if err != nil {
return err
}
if initialize {
if err := newFirewallService().applyPortWhitelist(t.TaskCtx, ports, nil, filter.Family(request.Family)); err != nil {
return err return err
} }
} }
if request.Operation == "initialize" { return settingRepo.UpdateOrCreate("IptablesStatus", constant.StatusEnable)
if err := newDockerPortGuardService().Operate(ctx, dto.DockerPortGuardOperation{Operation: "initialize"}); err != nil { case "forwarding":
_ = settingRepo.UpdateOrCreate(constant.FirewallDockerBackendKey, previous) forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
manager, err := newForwardingAdapter(t.TaskCtx)
if err != nil {
return err return err
} }
if manager.Name() != request.Backend {
return filter.ErrProviderUnavailable
} }
return nil if err := manager.OperateFamily(request.Family, initialize); err != nil {
return err
}
rules, err := manager.List()
if err != nil {
return err
}
if err := persistForwardingRules(manager, rules); err != nil {
return err
}
return settingRepo.UpdateOrCreate(constant.FirewallForwardingInitializedKey, constant.StatusEnable)
default:
dockerPortGuardServiceMu.Lock()
defer dockerPortGuardServiceMu.Unlock()
runtime, backend, err := newDockerPortGuardService().runtimeForDocker(t.TaskCtx)
if err != nil {
return err
}
if backend != request.Backend {
return filter.ErrProviderUnavailable
}
if err := runtime.OperateFamily(request.Family, initialize); err != nil {
return err
}
inventory, err := runtime.ListPolicies()
if err != nil {
return err
}
if err := persistDockerRules(backend, inventory); err != nil {
return err
}
return settingRepo.UpdateOrCreate(constant.FirewallDockerPortGuardStatusKey, constant.StatusEnable)
}
})
} }
func dockerGuardBackendInitialized(backend string) (bool, error) { func (s *FirewallSettingService) OperateIPv6(request dto.FirewallIPv6Operation) (dto.FilterChainOperationResponse, error) {
guard := docker_guard.NewRuntime(backend) if request.Status != constant.StatusEnable && request.Status != constant.StatusDisable {
for _, family := range []string{docker_guard.FamilyIPv4, docker_guard.FamilyIPv6} { return dto.FilterChainOperationResponse{}, filter.ErrInvalidRule
initialized, err := guard.Initialized(family) }
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeFirewall, 0); err != nil {
return dto.FilterChainOperationResponse{}, err
}
return queueFirewallRuleTask(firewallTaskHost, task.TaskExec, "", nil, func(t *task.Task) error {
if err := lockFirewallLifecycleIdle(); err != nil {
return err
}
defer firewallLifecycleTaskMu.Unlock()
firewallWhitelistMu.Lock()
defer firewallWhitelistMu.Unlock()
firewallRuleMutationMu.Lock()
defer firewallRuleMutationMu.Unlock()
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
dockerPortGuardServiceMu.Lock()
defer dockerPortGuardServiceMu.Unlock()
if request.Status == constant.StatusEnable {
return settingRepo.UpdateOrCreate(constant.FirewallIPv6SupportKey, request.Status)
}
ports, err := loadFirewallPortWhiteList()
if err != nil { if err != nil {
return false, err return err
} }
if initialized { value, err := json.Marshal(ipv4PortWhitelist(ports))
return true, nil if err != nil {
return err
}
installed := lifecycle.InstalledProviders()
for _, selection := range []struct{ subsystem, key string }{
{"system", constant.FirewallSystemBackendKey},
{"forwarding", constant.FirewallForwardingBackendKey},
{"docker", constant.FirewallDockerBackendKey},
} {
backend, err := settingRepo.GetValueByKey(selection.key)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
backend = strings.ToLower(strings.TrimSpace(backend))
if backend == "" {
if selection.subsystem == "system" {
if len(installed) == 0 {
continue
}
client, err := NewSelectedSystemFirewallClient()
if err != nil {
return err
}
backend = client.Name()
} else {
backend = constant.FirewallProviderIptables
} }
} }
return false, nil if !slices.Contains(installed, backend) || (backend != constant.FirewallProviderIptables && backend != constant.FirewallProviderNftables) {
continue
}
t.Logf("disable IPv6 firewall bindings: subsystem=%s backend=%s", selection.subsystem, backend)
switch selection.subsystem {
case "system":
if backend == constant.FirewallProviderIptables {
err = iptables_helper.UnbindIPv6BaseChains()
} else {
err = nftables_helper.SetTableDormant(t.TaskCtx, "ip6", nftables_helper.TableName)
if err == nil {
err = nftables_helper.PersistRuleset(t.TaskCtx)
}
}
case "forwarding":
var manager forwarding.Adapter
manager, err = newForwardingAdapterFor(t.TaskCtx, backend)
if err == nil {
err = manager.UnbindFamily(constant.FirewallFamilyIPv6)
}
case "docker":
err = newDockerFirewallRuntime(t.TaskCtx, backend).Unbind(constant.FirewallFamilyIPv6)
}
if err != nil && !errors.Is(err, filter.ErrFamilyUnavailable) {
return err
}
}
if err := t.TaskCtx.Err(); err != nil {
return err
}
return settingRepo.UpdateValues(map[string]string{constant.FirewallIPv6SupportKey: request.Status, constant.FirewallPortWhiteList: string(value)})
})
}
func NewIFirewallSettingService() IFirewallSettingService {
return &FirewallSettingService{}
} }
func (s *FirewallSettingService) operateSystem(request dto.FirewallBackendOperation) error { func (s *FirewallSettingService) operateSystem(request dto.FirewallBackendOperation) error {
firewallRuleMutationMu.Lock() firewallRuleMutationMu.Lock()
defer firewallRuleMutationMu.Unlock() defer firewallRuleMutationMu.Unlock()
if _, err := lifecycle.NewClientFor(request.Backend); err != nil { if _, err := lifecycle.NewClient(request.Backend); err != nil {
return err return err
} }
if request.Operation == "cleanup" {
return cleanupSystemBackend(request.Backend)
}
previous, _ := settingRepo.GetValueByKey(constant.FirewallSystemBackendKey) previous, _ := settingRepo.GetValueByKey(constant.FirewallSystemBackendKey)
if previous == "" { if previous == "" {
if client, err := lifecycle.NewClient(); err == nil { if client, err := lifecycle.NewClient(""); err == nil {
previous = client.Name() previous = client.Name()
} }
} }
@@ -486,7 +534,7 @@ func (s *FirewallSettingService) operateSystem(request dto.FirewallBackendOperat
return err return err
} }
if initialized { if initialized {
return firewallBackendCleanupRequired(previous, request.Backend) return buserr.WithMap("ErrFirewallBackendCleanupRequired", map[string]interface{}{"current": previous, "target": request.Backend}, nil)
} }
} }
if err := settingRepo.UpdateOrCreate(constant.FirewallSystemBackendKey, request.Backend); err != nil { if err := settingRepo.UpdateOrCreate(constant.FirewallSystemBackendKey, request.Backend); err != nil {
@@ -512,21 +560,14 @@ func (s *FirewallSettingService) operateSystem(request dto.FirewallBackendOperat
} }
func systemFirewallBackendInitialized(backend string) (bool, error) { func systemFirewallBackendInitialized(backend string) (bool, error) {
return systemFirewallBackendInitializedWithClientFactory(backend, lifecycle.NewClientFor) client, err := lifecycle.NewClient(backend)
}
func systemFirewallBackendInitializedWithClientFactory(
backend string,
newClient func(string) (lifecycle.Client, error),
) (bool, error) {
client, err := newClient(backend)
if err != nil { if err != nil {
if errors.Is(err, lifecycle.ErrNotInstalled) { if errors.Is(err, lifecycle.ErrNotInstalled) {
return false, nil return false, nil
} }
return false, err return false, err
} }
if supportsManagedFilterChains(backend) { if backend == constant.FirewallProviderIptables || backend == constant.FirewallProviderNftables {
for _, family := range []string{constant.FirewallFamilyIPv4, constant.FirewallFamilyIPv6} { for _, family := range []string{constant.FirewallFamilyIPv4, constant.FirewallFamilyIPv6} {
initialized, _, err := loadSystemFirewallFamilyStatus(backend, family) initialized, _, err := loadSystemFirewallFamilyStatus(backend, family)
if family == constant.FirewallFamilyIPv6 && errors.Is(err, filter.ErrFamilyUnavailable) { if family == constant.FirewallFamilyIPv6 && errors.Is(err, filter.ErrFamilyUnavailable) {
@@ -544,73 +585,39 @@ func systemFirewallBackendInitializedWithClientFactory(
return client.Status() return client.Status()
} }
func cleanupSystemBackend(backend string) error { func (s *FirewallSettingService) operateForwarding(ctx context.Context, request dto.FirewallBackendOperation) error {
switch backend { if _, err := newForwardingAdapterFor(ctx, request.Backend); err != nil {
case constant.FirewallProviderIptables:
return newIptablesHelperManager().Cleanup()
case constant.FirewallProviderNftables:
return newNftablesHelperManager().Cleanup()
default:
return fmt.Errorf("cleanup is only available for 1Panel-owned iptables and nftables resources")
}
}
func cleanupInactiveSystemBackend(backend string) error {
switch backend {
case constant.FirewallProviderIptables:
return (&iptables_helper.Manager{}).Cleanup()
case constant.FirewallProviderNftables:
return (&nftables_helper.Manager{}).Cleanup()
default:
return fmt.Errorf("cleanup is only available for 1Panel-owned iptables and nftables resources")
}
}
func (s *FirewallSettingService) operateForwarding(request dto.FirewallBackendOperation) error {
manager, err := newForwardingManagerFor(request.Backend)
if err != nil {
return err return err
} }
if request.Operation == "cleanup" {
if err := manager.Cleanup(); err != nil {
return err
}
if err := settingRepo.UpdateOrCreate(constant.FirewallForwardingInitializedKey, constant.StatusDisable); err != nil {
return err
}
recordForwardingSyncError(nil)
return nil
}
previous, _ := settingRepo.GetValueByKey(constant.FirewallForwardingBackendKey) previous, _ := settingRepo.GetValueByKey(constant.FirewallForwardingBackendKey)
if request.Operation == "select" { if request.Operation == "select" {
current := previous current := previous
if current == "" { if current == "" {
detected, err := newForwardingManager() detected, err := newForwardingAdapter(ctx)
if err != nil { if err != nil {
return err return err
} }
current = detected.Name() current = detected.Name()
} }
initialized, err := forwardingBackendInitialized(current) initialized, err := forwardingBackendInitialized(ctx, current)
if err != nil { if err != nil {
return err return err
} }
if current != request.Backend && initialized { if current != request.Backend && initialized {
return firewallBackendCleanupRequired(current, request.Backend) return buserr.WithMap("ErrFirewallBackendCleanupRequired", map[string]interface{}{"current": current, "target": request.Backend}, nil)
} }
} }
if err := settingRepo.UpdateOrCreate(constant.FirewallForwardingBackendKey, request.Backend); err != nil { if err := settingRepo.UpdateOrCreate(constant.FirewallForwardingBackendKey, request.Backend); err != nil {
return err return err
} }
if request.Operation == "initialize" { if request.Operation == "initialize" {
return newForwardingService().Enable() return newForwardingService().Enable(ctx)
} }
recordForwardingSyncError(nil)
return nil return nil
} }
func forwardingBackendInitialized(backend string) (bool, error) { func forwardingBackendInitialized(ctx context.Context, backend string) (bool, error) {
manager, err := newForwardingManagerFor(backend) manager, err := newForwardingAdapterFor(ctx, backend)
if err != nil { if err != nil {
if errors.Is(err, lifecycle.ErrNotInstalled) { if errors.Is(err, lifecycle.ErrNotInstalled) {
return false, nil return false, nil
@@ -629,9 +636,53 @@ func forwardingBackendInitialized(backend string) (bool, error) {
return false, nil return false, nil
} }
func alternateDirectBackend(backend string) string { func (s *FirewallSettingService) operateDocker(ctx context.Context, request dto.FirewallBackendOperation) error {
if backend == constant.FirewallProviderNftables {
return constant.FirewallProviderIptables previous, _ := settingRepo.GetValueByKey(constant.FirewallDockerBackendKey)
if request.Operation == "select" {
current := previous
if current == "" {
current = constant.FirewallProviderNftables
if request.Backend == constant.FirewallProviderNftables {
current = constant.FirewallProviderIptables
} }
return constant.FirewallProviderNftables }
initialized, err := dockerGuardBackendInitialized(ctx, current)
if err != nil {
return err
}
if current != request.Backend && initialized {
return buserr.WithMap("ErrFirewallBackendCleanupRequired", map[string]interface{}{"current": current, "target": request.Backend}, nil)
}
}
if err := settingRepo.UpdateOrCreate(constant.FirewallDockerBackendKey, request.Backend); err != nil {
return err
}
if request.Operation == "select" {
if err := (&DockerService{}).UpdateFirewallBackend(request.Backend); err != nil {
_ = settingRepo.UpdateOrCreate(constant.FirewallDockerBackendKey, previous)
return err
}
}
if request.Operation == "initialize" {
if err := newDockerPortGuardService().Operate(ctx, dto.DockerPortGuardOperation{Operation: "initialize"}); err != nil {
_ = settingRepo.UpdateOrCreate(constant.FirewallDockerBackendKey, previous)
return err
}
}
return nil
}
func dockerGuardBackendInitialized(ctx context.Context, backend string) (bool, error) {
guard := newDockerFirewallRuntime(ctx, backend)
for _, family := range []string{dockerfirewall.FamilyIPv4, dockerfirewall.FamilyIPv6} {
initialized, err := guard.Initialized(family)
if err != nil {
return false, err
}
if initialized {
return true, nil
}
}
return false, nil
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+189 -478
View File
@@ -4,78 +4,56 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/firewall" "github.com/1Panel-dev/1Panel/agent/utils/firewall"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding" "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/lifecycle" "github.com/1Panel-dev/1Panel/agent/utils/firewall/lifecycle"
) )
type IForwardingService interface { type IForwardingService interface {
LoadBaseInfo() (dto.FirewallSubsystemStatus, error) LoadBaseInfo(ctx context.Context) (dto.FirewallSubsystemStatus, error)
SearchRules(request dto.ForwardRuleSearch) (int64, []dto.ForwardRule, error) ExportBackup(context.Context, filter.Provider) (dto.FirewallSubsystemBackup, error)
SearchRules(ctx context.Context, request dto.ForwardRuleSearch) (int64, []dto.ForwardRule, error)
OperateRules(dto.ForwardRuleOperate) (dto.FilterChainOperationResponse, error) OperateRules(dto.ForwardRuleOperate) (dto.FilterChainOperationResponse, error)
Enable() error Enable(ctx context.Context) error
QueueInitialization(dto.FirewallInitializationTask) (dto.FilterChainOperationResponse, error) QueueInitialization(dto.FirewallInitializationTask) (dto.FilterChainOperationResponse, error)
Restore(context.Context) error Restore(context.Context) error
} }
type ForwardingService struct { type ForwardingService struct {
managerFactory func() (*forwarding.Manager, error) clientFactory func(context.Context) (forwarding.Adapter, error)
rules repo.IForwardingRuleRepo
enabled func() (bool, error)
persistBackend func(string) error
markEnabled func() error
} }
var errForwardingBackendUnavailable = errors.New("no supported forwarding backend detected") var errForwardingBackendUnavailable = errors.New("no supported forwarding backend detected")
var forwardingMutationMu sync.Mutex var forwardingMutationMu sync.Mutex
const ( func (s *ForwardingService) LoadBaseInfo(ctx context.Context) (dto.FirewallSubsystemStatus, error) {
forwardingSyncConverged = "converged" families, err := loadFirewallFamilies()
forwardingSyncMissing = "missing" if err != nil {
forwardingSyncRuntimeOnly = "runtime_only" return dto.FirewallSubsystemStatus{}, err
) }
selected, _ := settingRepo.GetValueByKey(constant.FirewallForwardingBackendKey)
var ( selected = strings.TrimSpace(selected)
forwardingSyncStateMu sync.RWMutex if selected == "" {
forwardingLastSyncErr error selected = constant.FirewallProviderIptables
)
func NewIForwardingService() IForwardingService {
return newForwardingService()
}
func newForwardingService() *ForwardingService {
return &ForwardingService{
managerFactory: newForwardingManager,
rules: repo.NewIForwardingRuleRepo(),
enabled: forwardingPersistedEnabled,
markEnabled: func() error {
return settingRepo.UpdateOrCreate(constant.FirewallForwardingInitializedKey, constant.StatusEnable)
},
persistBackend: func(backend string) error {
return settingRepo.UpdateOrCreate(constant.FirewallForwardingBackendKey, backend)
},
} }
}
func (s *ForwardingService) LoadBaseInfo() (dto.FirewallSubsystemStatus, error) {
selected := configuredForwardingBackend()
baseInfo := dto.FirewallSubsystemStatus{ baseInfo := dto.FirewallSubsystemStatus{
Version: "-", Name: forwardingDisplayName(selected), Backend: selected, SyncError: lastForwardingSyncError(), Version: "-", Name: selected, Backend: selected, IPv6Enabled: len(families) > 1,
} }
manager, err := s.managerFactory() if selected == constant.FirewallProviderIptables || selected == constant.FirewallProviderNftables {
baseInfo.Name += "-forward"
}
manager, err := s.clientFactory(ctx)
if err != nil { if err != nil {
if errors.Is(err, errForwardingBackendUnavailable) { if errors.Is(err, errForwardingBackendUnavailable) {
baseInfo.Reason = constant.FirewallBackendNotInstalled baseInfo.Reason = constant.FirewallBackendNotInstalled
@@ -83,119 +61,83 @@ func (s *ForwardingService) LoadBaseInfo() (dto.FirewallSubsystemStatus, error)
} }
return baseInfo, err return baseInfo, err
} }
status, err := manager.Status() client, err := lifecycle.NewClient(manager.Name())
if err != nil { if err != nil {
return baseInfo, err return baseInfo, err
} }
version, versionErr := client.Version()
status, statusErr := loadForwardingFirewallOverview(manager, families)
if err := errors.Join(versionErr, statusErr); err != nil {
return baseInfo, err
}
baseInfo.IsExist = true baseInfo.IsExist = true
baseInfo.Name, baseInfo.Backend = forwardingDisplayName(status.Name), status.Name baseInfo.Name, baseInfo.Backend = manager.Name(), manager.Name()
baseInfo.Version = status.Version if baseInfo.Backend == constant.FirewallProviderIptables || baseInfo.Backend == constant.FirewallProviderNftables {
baseInfo.Name += "-forward"
}
baseInfo.Version = version
baseInfo.PingStatus = firewall.LoadPingStatus() baseInfo.PingStatus = firewall.LoadPingStatus()
baseInfo.IsInit, baseInfo.IsBind = status.IsInit, status.IsBind baseInfo.IsInit, baseInfo.IsBind = status.IsInit, status.IsBind
baseInfo.IPv4 = loadForwardingFamilyInfo(manager, status.Name, constant.FirewallFamilyIPv4) baseInfo.IPv4, baseInfo.IPv6 = status.IPv4, status.IPv6
baseInfo.IPv6 = loadForwardingFamilyInfo(manager, status.Name, constant.FirewallFamilyIPv6)
return baseInfo, nil return baseInfo, nil
} }
func loadForwardingFamilyInfo(manager *forwarding.Manager, backend, family string) dto.FirewallBackendFamilyStatus { func (s *ForwardingService) ExportBackup(ctx context.Context, provider filter.Provider) (dto.FirewallSubsystemBackup, error) {
initialized, bound, err := manager.FamilyStatus(family) var manager forwarding.Adapter
available := err == nil var err error
if backend == constant.FirewallProviderIptables && family == constant.FirewallFamilyIPv6 { if provider == "" {
commands, commandErr := lifecycle.ResolveIptablesCommands() manager, err = s.clientFactory(ctx)
available = available && commandErr == nil && commands.IPv6Available() } else {
manager, err = newForwardingAdapterFor(ctx, string(provider))
} }
return dto.FirewallBackendFamilyStatus{Available: available, Initialized: initialized, Bound: bound} if err != nil {
return dto.FirewallSubsystemBackup{}, err
}
rules, err := manager.List()
if err != nil {
return dto.FirewallSubsystemBackup{}, err
}
return forwardingBackup(manager, rules)
} }
func forwardingDisplayName(backend string) string { func (s *ForwardingService) SearchRules(ctx context.Context, request dto.ForwardRuleSearch) (int64, []dto.ForwardRule, error) {
switch backend { manager, err := s.clientFactory(ctx)
case constant.FirewallProviderIptables, constant.FirewallProviderNftables:
return backend + "-forward"
default:
return backend
}
}
func (s *ForwardingService) SearchRules(request dto.ForwardRuleSearch) (int64, []dto.ForwardRule, error) {
if request.Strategy != "" {
return 0, nil, nil
}
stored, err := s.rules.List(context.Background())
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
manager, err := s.managerFactory() rules, err := manager.List()
if err != nil {
return 0, nil, err
}
runtime, err := manager.List("", "")
if err != nil {
return 0, nil, err
}
inventory, err := mergeForwardingInventory(stored, runtime)
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
items := make([]dto.ForwardRule, 0, len(rules))
keyword := strings.ToLower(strings.TrimSpace(request.Info)) keyword := strings.ToLower(strings.TrimSpace(request.Info))
filtered := inventory[:0] for _, rule := range rules {
for _, item := range inventory { if keyword != "" && !strings.Contains(strings.ToLower(strings.Join([]string{rule.Family, rule.Protocol, rule.Port, rule.TargetIP, rule.TargetPort, rule.Interface}, " ")), keyword) {
if keyword == "" || forwardingRuleMatchesKeyword(item, keyword) { continue
filtered = append(filtered, item)
} }
items = append(items, dto.ForwardRule{Num: strconv.Itoa(len(items) + 1), Family: rule.Family, Protocol: rule.Protocol, Port: rule.Port, TargetIP: rule.TargetIP, TargetPort: rule.TargetPort, Interface: rule.Interface})
} }
inventory = filtered total := len(items)
total := len(inventory)
start, end := (request.Page-1)*request.PageSize, request.Page*request.PageSize
if request.All { if request.All {
start, end = 0, total
}
if start > total {
return int64(total), make([]dto.ForwardRule, 0), nil
}
if end > total {
end = total
}
pageRules := inventory[start:end]
var items []dto.ForwardRule
if pageRules != nil {
items = make([]dto.ForwardRule, 0, len(pageRules))
}
for index, item := range pageRules {
items = append(items, dto.ForwardRule{
ID: item.ID,
Num: strconv.Itoa(start + index + 1),
Family: item.Rule.Family,
Protocol: item.Rule.Protocol,
Port: item.Rule.Port,
TargetIP: item.Rule.TargetIP,
TargetPort: item.Rule.TargetPort,
Interface: item.Rule.Interface,
IsDesired: item.IsDesired,
IsRuntime: item.IsRuntime,
SyncStatus: item.SyncStatus(),
})
}
return int64(total), items, nil return int64(total), items, nil
}
func forwardingRuleMatchesKeyword(item forwardingInventoryItem, keyword string) bool {
values := []string{
item.Rule.Family, item.Rule.Protocol, item.Rule.Port, item.Rule.TargetIP,
item.Rule.TargetPort, item.Rule.Interface, item.SyncStatus(),
} }
for _, value := range values { start := min(max(request.Page-1, 0)*max(request.PageSize, 1), total)
if strings.Contains(strings.ToLower(value), keyword) { end := min(start+max(request.PageSize, 1), total)
return true return int64(total), items[start:end], nil
}
}
return false
} }
func (s *ForwardingService) OperateRules(request dto.ForwardRuleOperate) (dto.FilterChainOperationResponse, error) { func (s *ForwardingService) OperateRules(request dto.ForwardRuleOperate) (dto.FilterChainOperationResponse, error) {
labels := make([]string, len(request.Rules)) count := 0
for _, rule := range request.Rules {
if rule.Operation == "add" {
count += strings.Count(rule.Protocol, "/") + 1
}
if count > filter.MaxAtomicExpansion {
return dto.FilterChainOperationResponse{}, fmt.Errorf("create or import at most %d rules per batch (after expansion)", filter.MaxAtomicExpansion)
}
}
operation := task.TaskCreate operation := task.TaskCreate
for i, rule := range request.Rules { for _, rule := range request.Rules {
labels[i] = fmt.Sprintf("[%d/%d] %s %s %s %s -> %s:%s", i+1, len(request.Rules), rule.Operation, rule.Family, rule.Protocol, rule.Port, rule.TargetIP, rule.TargetPort)
if rule.Operation != "add" { if rule.Operation != "add" {
operation = task.TaskUpdate operation = task.TaskUpdate
} }
@@ -203,375 +145,144 @@ func (s *ForwardingService) OperateRules(request dto.ForwardRuleOperate) (dto.Fi
if forwardingOperationsOnlyRemove(request.Rules) { if forwardingOperationsOnlyRemove(request.Rules) {
operation = task.TaskDelete operation = task.TaskDelete
} }
return queueFirewallRuleTask(firewallTaskForwarding, operation, labels, func(ctx context.Context) error { taskItem, err := task.NewTask(firewallTaskName(operation, firewallTaskForwarding, ""), operation, task.TaskScopeFirewall, "", 0)
return s.operateRules(ctx, request)
})
}
func (s *ForwardingService) operateRules(ctx context.Context, request dto.ForwardRuleOperate) error {
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
stored, err := s.rules.List(ctx)
if err != nil { if err != nil {
return err
}
desired, err := applyForwardingOperations(forwardingRulesFromModels(stored), request.Rules)
if errors.Is(err, forwarding.ErrRuleExists) {
return buserr.New("ErrRecordExist")
} else if err != nil {
return err
}
if err := s.rules.ReplaceAll(ctx, forwardingRuleModels(desired)); err != nil {
return err
}
if err := s.reconcile(desired); err != nil {
recordForwardingSyncError(err)
if request.ForceDelete && forwardingOperationsOnlyRemove(request.Rules) {
if global.LOG != nil {
global.LOG.Error(err)
}
return nil
}
return err
}
recordForwardingSyncError(nil)
return nil
}
func (s *ForwardingService) Enable() error {
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
manager, err := s.managerFactory()
if err != nil {
recordForwardingSyncError(err)
return err
}
if err := s.persistForwardingEnabled(); err != nil {
recordForwardingSyncError(err)
return err
}
if err := s.activateManager(manager); err != nil {
recordForwardingSyncError(err)
return err
}
rules, err := s.rules.List(context.Background())
if err != nil {
recordForwardingSyncError(err)
return err
}
err = manager.Reconcile(forwardingRulesFromModels(rules))
recordForwardingSyncError(err)
return err
}
func (s *ForwardingService) QueueInitialization(
request dto.FirewallInitializationTask,
) (dto.FilterChainOperationResponse, error) {
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeFirewall, 0); err != nil {
return dto.FilterChainOperationResponse{}, err return dto.FilterChainOperationResponse{}, err
} }
taskItem, err := task.NewTask(firewallTaskName(task.TaskExec, firewallTaskForwarding, ""), task.TaskExec, task.TaskScopeFirewall, request.TaskID, 0) taskItem.AddSubTaskWithOps(taskItem.Name, func(t *task.Task) error {
if err != nil { return s.operateRules(t.TaskCtx, request, t, false)
return dto.FilterChainOperationResponse{}, fmt.Errorf("create forwarding initialization task: %w", err) }, nil, 0, 0)
} if err := taskRepo.Save(context.Background(), taskItem.Task); err != nil {
var manager *forwarding.Manager taskItem.LogFailedWithErr(taskItem.Name, err)
var backend string closeUnstartedFirewallTask(taskItem)
taskItem.AddSubTask(i18n.GetMsgByKey("FirewallEnableForwardingStep"), func(t *task.Task) error { return dto.FilterChainOperationResponse{}, err
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
var err error
manager, err = s.managerFactory()
if err != nil {
recordForwardingSyncError(err)
return err
}
backend = manager.Name()
t.Logf("backend=%s", backend)
if err := s.persistForwardingEnabled(); err != nil {
recordForwardingSyncError(err)
return err
}
if err := s.activateManager(manager); err != nil {
recordForwardingSyncError(err)
return err
}
return nil
}, nil)
taskItem.AddSubTask(i18n.GetMsgByKey("FirewallRestoreForwardingRulesStep"), func(t *task.Task) error {
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
rules, err := s.rules.List(t.TaskCtx)
if err != nil {
recordForwardingSyncError(err)
return err
}
err = manager.Reconcile(forwardingRulesFromModels(rules))
recordForwardingSyncError(err)
return err
}, nil)
if err := repo.NewITaskRepo().Save(context.Background(), taskItem.Task); err != nil {
return dto.FilterChainOperationResponse{}, fmt.Errorf("save forwarding initialization task: %w", err)
} }
go func() { _ = taskItem.Execute() }() go func() { _ = taskItem.Execute() }()
return dto.FilterChainOperationResponse{TaskID: taskItem.TaskID, Queued: true}, nil return dto.FilterChainOperationResponse{TaskID: taskItem.TaskID, Queued: true}, nil
} }
func (s *ForwardingService) Enable(ctx context.Context) error {
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
manager, err := s.clientFactory(ctx)
if err != nil {
return err
}
rules, err := manager.List()
if err != nil {
return err
}
families, err := loadFirewallFamilies()
if err != nil {
return err
}
if err := initializeForwarding(manager, families); err != nil {
return err
}
return persistForwardingRules(manager, rules)
}
func (s *ForwardingService) QueueInitialization(request dto.FirewallInitializationTask) (dto.FilterChainOperationResponse, error) {
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeFirewall, 0); err != nil {
return dto.FilterChainOperationResponse{}, err
}
var backup dto.FirewallSubsystemBackup
if request.BackupFile != "" {
var err error
backup, err = readFirewallSubsystemBackup(request.BackupFile, "forwarding")
if err != nil {
return dto.FilterChainOperationResponse{}, err
}
}
operations := dto.ForwardRuleOperate{Rules: make([]dto.ForwardRuleOperation, 0, len(backup.Forwarding))}
for _, rule := range backup.Forwarding {
operations.Rules = append(operations.Rules, dto.ForwardRuleOperation{Operation: "add", Family: rule.Family, Protocol: rule.Protocol, Port: rule.Port, TargetIP: rule.TargetIP, TargetPort: rule.TargetPort, Interface: rule.Interface})
}
return queueFirewallRuleTask(firewallTaskForwarding, task.TaskExec, request.TaskID, nil, func(t *task.Task) error {
return s.operateRules(t.TaskCtx, operations, t, true, backup.Families...)
})
}
func (s *ForwardingService) Restore(ctx context.Context) error { func (s *ForwardingService) Restore(ctx context.Context) error {
forwardingMutationMu.Lock() forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock() defer forwardingMutationMu.Unlock()
enabled, err := s.forwardingEnabled()
if err != nil || !enabled {
if err != nil {
recordForwardingSyncError(err)
}
return err
}
manager, err := s.managerFactory()
if err != nil {
recordForwardingSyncError(err)
return err
}
stored, err := s.rules.List(ctx)
if err != nil {
recordForwardingSyncError(err)
return err
}
if err := s.activateManager(manager); err != nil {
recordForwardingSyncError(err)
return err
}
err = manager.Reconcile(forwardingRulesFromModels(stored))
recordForwardingSyncError(err)
return err
}
func (s *ForwardingService) reconcile(rules []forwarding.Rule) error {
manager, err := s.managerFactory()
if err != nil {
return err
}
return s.reconcileWithManager(manager, rules)
}
func (s *ForwardingService) reconcileWithManager(manager *forwarding.Manager, rules []forwarding.Rule) error {
enabled, err := s.forwardingEnabled()
if err != nil || !enabled {
return err
}
if err := s.activateManager(manager); err != nil {
return err
}
return manager.Reconcile(rules)
}
func (s *ForwardingService) activateManager(manager *forwarding.Manager) error {
if err := s.saveForwardingBackend(manager.Name()); err != nil {
return err
}
return manager.Enable()
}
func (s *ForwardingService) forwardingEnabled() (bool, error) {
if s.enabled != nil {
return s.enabled()
}
return forwardingPersistedEnabled()
}
func (s *ForwardingService) saveForwardingBackend(backend string) error {
if s.persistBackend != nil {
return s.persistBackend(backend)
}
return settingRepo.UpdateOrCreate(constant.FirewallForwardingBackendKey, backend)
}
func (s *ForwardingService) persistForwardingEnabled() error {
if s.markEnabled != nil {
return s.markEnabled()
}
return settingRepo.UpdateOrCreate(constant.FirewallForwardingInitializedKey, constant.StatusEnable)
}
func forwardingPersistedEnabled() (bool, error) {
status, err := settingRepo.GetValueByKey(constant.FirewallForwardingInitializedKey) status, err := settingRepo.GetValueByKey(constant.FirewallForwardingInitializedKey)
return status == constant.StatusEnable, err if err != nil || status != constant.StatusEnable {
} return err
func forwardingRulesFromModels(stored []model.ForwardingRule) []forwarding.Rule {
rules := make([]forwarding.Rule, 0, len(stored))
for _, rule := range stored {
rules = append(rules, forwarding.Rule{
Family: rule.Family, Protocol: rule.Protocol, Port: rule.Port, TargetIP: rule.TargetIP,
TargetPort: rule.TargetPort, Interface: rule.Interface,
})
} }
return rules manager, err := s.clientFactory(ctx)
}
func forwardingRuleModels(rules []forwarding.Rule) []model.ForwardingRule {
stored := make([]model.ForwardingRule, 0, len(rules))
for _, rule := range rules {
stored = append(stored, model.ForwardingRule{
Family: rule.Family, Protocol: rule.Protocol, Port: rule.Port, TargetIP: rule.TargetIP,
TargetPort: rule.TargetPort, Interface: rule.Interface,
})
}
return stored
}
type forwardingInventoryItem struct {
ID uint
Rule forwarding.Rule
IsDesired bool
IsRuntime bool
}
func (i forwardingInventoryItem) SyncStatus() string {
switch {
case i.IsDesired && i.IsRuntime:
return forwardingSyncConverged
case i.IsDesired:
return forwardingSyncMissing
default:
return forwardingSyncRuntimeOnly
}
}
func mergeForwardingInventory(
stored []model.ForwardingRule,
runtime []forwarding.Rule,
) ([]forwardingInventoryItem, error) {
items := make([]forwardingInventoryItem, 0, len(stored)+len(runtime))
byIdentity := make(map[string]int, len(stored)+len(runtime))
for _, record := range stored {
rule, err := forwarding.NormalizeRule(forwarding.Rule{
Family: record.Family, Protocol: record.Protocol, Port: record.Port, TargetIP: record.TargetIP,
TargetPort: record.TargetPort, Interface: record.Interface,
})
if err != nil { if err != nil {
return nil, fmt.Errorf("normalize desired forwarding rule: %w", err) return err
} }
key := rule.Identity() backup, err := readFirewallSubsystemBackup("forwarding-"+manager.Name()+".rules", "forwarding")
byIdentity[key] = len(items) if errors.Is(err, os.ErrNotExist) {
items = append(items, forwardingInventoryItem{ID: record.ID, Rule: rule, IsDesired: true}) return nil
} }
for _, observed := range runtime {
rule, err := forwarding.NormalizeRule(observed)
if err != nil { if err != nil {
return nil, fmt.Errorf("normalize runtime forwarding rule: %w", err) return err
} }
key := rule.Identity() families, err := loadFirewallFamilies()
if index, exists := byIdentity[key]; exists { if err != nil {
items[index].IsRuntime = true return err
}
if !slices.Contains(families, constant.FirewallFamilyIPv6) {
before := len(backup.Forwarding)
backup.Forwarding = slices.DeleteFunc(backup.Forwarding, func(rule forwarding.Rule) bool { return rule.Family == constant.FirewallFamilyIPv6 })
logFirewallIPv6Skipped(nil, "forwarding startup", before-len(backup.Forwarding))
}
missing := make(map[string]bool)
needsBind := false
for _, family := range families {
initialized, bound, err := manager.FamilyStatus(family)
if err != nil {
return err
}
missing[family] = !initialized
needsBind = needsBind || (initialized && !bound)
}
restoreIPv6 := slices.Contains(backup.Families, forwarding.FamilyIPv6)
for _, rule := range backup.Forwarding {
restoreIPv6 = restoreIPv6 || rule.Family == forwarding.FamilyIPv6
}
if !missing[forwarding.FamilyIPv4] && (!missing[forwarding.FamilyIPv6] || !restoreIPv6) && !needsBind {
return nil
}
current, err := manager.List()
if err != nil {
return err
}
seen := make(map[string]int, len(current))
for _, rule := range current {
seen[rule.Identity()]++
}
for _, family := range families {
if family == forwarding.FamilyIPv6 && missing[family] && !restoreIPv6 {
continue continue
} }
byIdentity[key] = len(items) if err := manager.OperateFamily(family, missing[family]); err != nil {
items = append(items, forwardingInventoryItem{Rule: rule, IsRuntime: true}) return err
} }
return items, nil if !missing[family] {
continue
}
rules := make([]forwarding.Rule, 0)
for _, rule := range backup.Forwarding {
if rule.Family != family {
continue
}
if seen[rule.Identity()] > 0 {
seen[rule.Identity()]--
continue
}
rules = append(rules, rule)
}
if err := manager.CreateRules(ctx, rules); err != nil {
return err
}
}
return nil
} }
func recordForwardingSyncError(err error) { func NewIForwardingService() IForwardingService {
forwardingSyncStateMu.Lock() return newForwardingService()
forwardingLastSyncErr = err
forwardingSyncStateMu.Unlock()
}
func lastForwardingSyncError() string {
forwardingSyncStateMu.RLock()
defer forwardingSyncStateMu.RUnlock()
if forwardingLastSyncErr == nil {
return ""
}
return forwardingLastSyncErr.Error()
}
func applyForwardingOperations(current []forwarding.Rule, requested []dto.ForwardRuleOperation) ([]forwarding.Rule, error) {
desired := make([]forwarding.Rule, 0, len(current)+len(requested))
for _, rule := range current {
normalized, err := forwarding.NormalizeRule(rule)
if err != nil {
return nil, fmt.Errorf("normalize persisted forwarding rule: %w", err)
}
desired = append(desired, normalized)
}
for _, operation := range requested {
for _, protocol := range strings.Split(operation.Protocol, "/") {
rule, err := forwarding.NormalizeRule(forwarding.Rule{
Family: operation.Family, Protocol: protocol, Port: operation.Port, TargetIP: operation.TargetIP,
TargetPort: operation.TargetPort, Interface: operation.Interface,
})
if err != nil {
return nil, err
}
index := forwardingRuleIndex(desired, rule)
switch forwarding.OperationType(operation.Operation) {
case forwarding.OperationAdd:
if index >= 0 {
return nil, forwarding.ErrRuleExists
}
desired = append(desired, rule)
case forwarding.OperationRemove:
if index >= 0 {
desired = append(desired[:index], desired[index+1:]...)
}
default:
return nil, fmt.Errorf("unsupported forwarding operation %q", operation.Operation)
}
}
}
return desired, nil
}
func forwardingRuleIndex(rules []forwarding.Rule, wanted forwarding.Rule) int {
wantedIdentity := wanted.Identity()
for index, rule := range rules {
if rule.Identity() == wantedIdentity {
return index
}
}
return -1
}
func forwardingOperationsOnlyRemove(operations []dto.ForwardRuleOperation) bool {
if len(operations) == 0 {
return false
}
for _, operation := range operations {
if operation.Operation != string(forwarding.OperationRemove) {
return false
}
}
return true
}
func newForwardingManager() (*forwarding.Manager, error) {
return newForwardingManagerFor(configuredForwardingBackend())
}
func configuredForwardingBackend() string {
selected, _ := settingRepo.GetValueByKey(constant.FirewallForwardingBackendKey)
selected = strings.TrimSpace(selected)
if selected == "" {
return constant.FirewallProviderIptables
}
return selected
}
func newForwardingManagerFor(backend string) (*forwarding.Manager, error) {
client, err := lifecycle.NewClientFor(backend)
if err != nil {
return nil, fmt.Errorf(
"%w: selected forwarding backend %s: %w",
errForwardingBackendUnavailable, backend, err,
)
}
adapter, err := forwarding.New(client.Name())
if err != nil {
return nil, err
}
return forwarding.NewManager(adapter, client), nil
} }
+126
View File
@@ -0,0 +1,126 @@
package service
import (
"regexp"
"sort"
"strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/common"
)
// Release names at https://github.com/NousResearch/hermes-agent/releases pair
// these historical calendar tags with their semantic versions. This includes
// every calendar version published in the 1Panel app store. Do not rewrite
// stored versions: backup metadata, restore and rollback retain the original tag.
var hermesHistoricalVersions = map[string]string{
"2026.3.12": "0.2.0",
"2026.3.17": "0.3.0",
"2026.3.23": "0.4.0",
"2026.3.28": "0.5.0",
"2026.3.30": "0.6.0",
"2026.4.3": "0.7.0",
"2026.4.8": "0.8.0",
"2026.4.13": "0.9.0",
"2026.4.16": "0.10.0",
"2026.4.23": "0.11.0",
"2026.4.30": "0.12.0",
"2026.5.7": "0.13.0",
"2026.5.16": "0.14.0",
"2026.5.28": "0.15.0",
"2026.5.29": "0.15.1",
"2026.5.29.2": "0.15.2",
"2026.6.5": "0.16.0",
"2026.6.19": "0.17.0",
"2026.7.1": "0.18.0",
"2026.7.7": "0.18.1",
"2026.7.7.2": "0.18.2",
"2026.7.20": "0.19.0",
"2026.7.30": "0.19.1",
"2026.8.3": "0.20.0",
"2026.8.13": "0.20.1",
"2026.8.16": "0.20.2",
"2026.8.16.2": "0.20.3",
"2026.8.18": "0.20.4",
"2026.8.19": "0.20.5",
"2026.8.27": "0.20.6",
"2026.8.31": "0.21.0",
"2026.9.7": "0.21.1",
"2026.9.11": "0.21.2",
"2026.9.14": "0.21.3",
"2026.9.21": "0.21.4",
"2026.9.24": "0.21.5",
}
var hermesReleaseVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
func isHermesApp(key string) bool {
return key == constant.AppHermesAgent || key == "local"+constant.AppHermesAgent
}
func hermesComparableVersion(version string) (string, bool) {
version = strings.TrimPrefix(version, "v")
if mapped, ok := hermesHistoricalVersions[version]; ok {
return mapped, true
}
parts := hermesReleaseVersion.FindStringSubmatch(version)
if len(parts) == 0 {
return "", false
}
for i, part := range parts[1:] {
n, err := strconv.Atoi(part)
if err != nil || (i == 0 && n >= 2000) {
// An unknown calendar tag must not become a huge semantic major.
return "", false
}
}
return version, true
}
func isNewerAppVersion(key, target, current string) bool {
if !isHermesApp(key) {
return common.CompareVersion(target, current)
}
targetVersion, targetOK := hermesComparableVersion(target)
currentVersion, currentOK := hermesComparableVersion(current)
return targetOK && currentOK && common.CompareVersion(targetVersion, currentVersion)
}
func isCrossAppVersion(key, current, target string) bool {
if !isHermesApp(key) {
return common.IsCrossVersion(current, target)
}
currentVersion, currentOK := hermesComparableVersion(current)
targetVersion, targetOK := hermesComparableVersion(target)
return currentOK && targetOK && common.IsCrossVersion(currentVersion, targetVersion)
}
func appVersionSortBefore(key, left, right string) bool {
if !isHermesApp(key) {
return common.CompareVersion(left, right)
}
lv, lok := hermesComparableVersion(left)
rv, rok := hermesComparableVersion(right)
if lok != rok {
return lok
}
if !lok {
return common.CompareVersion(left, right)
}
if lv != rv {
return common.CompareVersion(lv, rv)
}
// Prefer the official semantic spelling when both aliases are available.
return strings.TrimPrefix(left, "v") == lv && strings.TrimPrefix(right, "v") != rv
}
func sortAppVersions(key string, versions []string) []string {
if !isHermesApp(key) {
return common.GetSortedVersions(versions)
}
sort.SliceStable(versions, func(i, j int) bool {
return appVersionSortBefore(key, versions[i], versions[j])
})
return versions
}
+31 -16
View File
@@ -28,7 +28,8 @@ import (
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/pkg/archive" "github.com/docker/docker/pkg/jsonmessage"
"github.com/moby/go-archive"
) )
type ImageService struct{} type ImageService struct{}
@@ -325,18 +326,16 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
} }
go func() { go func() {
client, err := docker.NewDockerClient()
if err != nil {
taskItem.Log("Failed to create Docker client: " + err.Error())
return
}
defer client.Close()
for _, itemPath := range req.Paths { for _, itemPath := range req.Paths {
currentPath := itemPath currentPath := itemPath
itemName := path.Base(currentPath) itemName := path.Base(currentPath)
taskItem.AddSubTask(i18n.GetWithName("TaskImport", itemName), func(t *task.Task) error { taskItem.AddSubTask(i18n.GetWithName("TaskImport", itemName), func(t *task.Task) error {
taskItem.Logf("----------------- %s -----------------", itemName) taskItem.Logf("----------------- %s -----------------", itemName)
client, err := docker.NewDockerClient()
if err != nil {
return err
}
defer client.Close()
file, err := os.Open(currentPath) file, err := os.Open(currentPath)
if err != nil { if err != nil {
return err return err
@@ -347,14 +346,7 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
return err return err
} }
defer res.Body.Close() defer res.Body.Close()
content, err := io.ReadAll(res.Body) return consumeImageLoadResponse(res.Body, taskItem.Log)
if err != nil {
return err
}
if strings.Contains(string(content), "Error") {
return errors.New(string(content))
}
return nil
}, nil) }, nil)
} }
_ = taskItem.Execute() _ = taskItem.Execute()
@@ -362,6 +354,29 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
return nil return nil
} }
// Docker may report load failures in a successful HTTP response's JSON stream.
func consumeImageLoadResponse(reader io.Reader, log func(string)) error {
decoder := json.NewDecoder(reader)
for {
var message jsonmessage.JSONMessage
if err := decoder.Decode(&message); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
if message.Error != nil && message.Error.Message != "" {
return message.Error
}
if message.ErrorMessage != "" {
return errors.New(message.ErrorMessage)
}
if text := strings.TrimSpace(message.Stream); text != "" {
log(text)
}
}
}
func (u *ImageService) ImageSave(req dto.ImageSave) error { func (u *ImageService) ImageSave(req dto.ImageSave) error {
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskExport, task.TaskScopeImage, req.TaskID, 1) taskItem, err := task.NewTaskWithOps(req.Name, task.TaskExport, task.TaskScopeImage, req.TaskID, 1)
if err != nil { if err != nil {

Some files were not shown because too many files have changed in this diff Show More