Compare commits

...
Author SHA1 Message Date
wanghe-fit2cloud 680125d5b7 fix(disk): keep disk info when df exits with errors
df still prints valid filesystems when a mount point cannot be queried (for example a stale FUSE mount), but exits non-zero. The dashboard and the disk alert discarded the whole output in that case, so no disk was displayed. Keep using the output whenever it is not empty. Refs #13864.
2026-09-20 15:16:15 +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
ssongliu 0bad1b471f feat(core): add runtime diagnostics and pprof capture (#13852) 2026-09-17 15:46:06 +08:00
ssongliu 3814525edd fix(core): avoid enumerating authorized IP subnets (#13851)
Use net.IPNet.Contains to check CIDR membership directly and remove the address increment loop. Large authorized subnets no longer cause per-request address enumeration and excessive CPU usage.
2026-09-17 15:45:57 +08:00
ssongliu 8162dd1856 fix(container): improve inspect panel theme and text layout (#13849) 2026-09-17 15:45:48 +08:00
ssongliu e833787020 fix(firewall): preserve whitelist priority and rule ordering (#13845) 2026-09-17 13:02:08 +08:00
ssongliu 673ffac516 refactor(firewall): simplify whitelist configuration and rule protection (#13838) 2026-09-16 22:09:49 +08:00
ssongliu e864610015 fix(firewall): validate whitelist ports and sources in form (#13835) 2026-09-16 21:31:06 +08:00
ssongliu 78402e1b7d refactor(firewall): consolidate utilities and flatten packages (#13833) 2026-09-16 16:48:48 +08:00
ssongliu 782bc1e67c fix(firewall): manage SSH access and queue stop operations (#13831)
* fix(firewall): manage SSH access and queue stop operations

* fix(firewall): reconcile whitelist rules and sync differences
2026-09-16 16:45:33 +08:00
ssongliu 86e4ed6f64 perf(firewall): optimize large rule sets and queue deletions (#13829) 2026-09-16 15:34:10 +08:00
CityFun ee8bac39af style: Optimize the website configuration UI (#13828) 2026-09-16 14:48:57 +08:00
ssongliu fe742b9f41 fix(firewall): improve whitelist management and rule lifecycle (#13826) 2026-09-15 23:55:01 +08:00
CityFun 9a5bd9bcba fix: Fix the issue where Brotli settings cannot be saved when enabled in OpenResty (#13822) 2026-09-15 18:36:51 +08:00
ssongliu b9c8e39560 fix: validate Docker IPv4 forwarding (#13820) 2026-09-15 18:36:36 +08:00
CityFun 6b20ff0b13 feat: OpenClaw supports configuring model Max Tokens (#13818) 2026-09-15 18:36:24 +08:00
ssongliu 89bd32b6d4 fix(terminal): isolate persistent shortcut sessions (#13810) 2026-09-15 10:05:16 +08:00
ssongliu 005f240fb7 fix: improve firewall lifecycle and sync (#13809) 2026-09-15 10:04:53 +08:00
蘭 75da53e374 feat: Remote download supports server file name options and improves error handling (#13808)
* feat: Remote download supports server file name options and improves error handling

* feat: Remote download supports server file name options and improves error handling
2026-09-15 10:04:44 +08:00
ssongliu 2485b0aa5e fix: remove debug logs (#13807) 2026-09-14 17:36:23 +08:00
ssongliu ed51a5e1fa fix(firewall): split UFW all-protocol port ranges (#13806) 2026-09-14 15:46:14 +08:00
ssongliu 56870504ac fix: restrict terminal dock to super administrators (#13804) 2026-09-14 11:36:35 +08:00
ssongliu 7aefb47cc3 fix(firewall): improve rule loading, task labels and panel port cleanup (#13798) 2026-09-14 09:40:20 +08:00
ssongliu aba41c0aea feat(terminal): share connection menu and add node quick connect (#13787) 2026-09-14 09:40:15 +08:00
ssongliu 9300bf4141 refactor(firewall): queue rule operations and simplify synchronization (#13786) 2026-09-11 15:16:44 +08:00
王贺 b7ec17b3e3 style(terminal): refine terminal shortcut translations (#13785) 2026-09-11 11:07:06 +08:00
ssongliu 2fcfe56a30 refactor(firewall): queue rule operations and simplify synchronization (#13784) 2026-09-10 23:54:46 +08:00
CityFun 63b2d4e4d5 fix: Fix the issue where installing extended channel plugins fails in OpenClaw (#13782) 2026-09-10 18:11:49 +08:00
ssongliu 4cd77d8ee1 fix(firewall): simplify rule editing and reject duplicate adoption (#13779) 2026-09-10 18:11:36 +08:00
CityFun 53a7347bea fix: Fix an issue with pulling images when upgrading OpenList. (#13776) 2026-09-10 18:11:26 +08:00
ssongliu a02c25ebcc feat(terminal): add terminal button visibility setting (#13772) 2026-09-09 18:39:08 +08:00
CityFun 605c8cc6db fix: optimize self-signed certificate format (#13771) 2026-09-09 18:27:17 +08:00
ssongliu 033cc7c2d1 fix: sort containers by name and backup uploads by time (#13770) 2026-09-09 16:58:53 +08:00
蘭 7c1ddb5b4c fix: add localized message for download records not removed (#13757) 2026-09-09 15:33:56 +08:00
ssongliu eb0f5264d7 refactor: simplify firewall rule management and whitelist updates (#13758) 2026-09-09 15:33:24 +08:00
蘭 5ad12c6fe4 fix: improve progress percentage calculation for download status (#13755) 2026-09-09 13:54:05 +08:00
ssongliu 61dacce5e0 refactor: replace freetype captcha with opentype renderer (#13753) 2026-09-09 13:53:56 +08:00
ssongliu e3f0381a26 fix: preserve table selections when clicking row content (#13752) 2026-09-09 13:53:46 +08:00
ssongliu 6bc9dd96af fix: simplify firewall rule editing and dual-stack port handling (#13748) 2026-09-08 22:46:35 +08:00
蘭 e23f338b31 feat: Processing synchronous alert settings (#13747) 2026-09-08 20:45:07 +08:00
蘭 6e08b50e3c fix: Fix file timeout and retry processing for long transmission tasks (#13746) 2026-09-08 18:34:21 +08:00
ssongliu 536712cd55 feat(vm): add localized license introduction (#13745) 2026-09-08 18:29:05 +08:00
CityFun 191ff0cda4 fix: Fix an error when selecting a runtime environment version. (#13742) 2026-09-08 17:10:56 +08:00
ssongliu 671f781564 feat: add terminal session rules hint (#13744) 2026-09-08 16:58:54 +08:00
ssongliu 30dc36b95d feat: integrate virtual machine migration into XPack (#13740)
* feat: integrate virtual machine migration into XPack

* chore: remove virtual machine test files
2026-09-08 14:20:26 +08:00
蘭 fac4aec680 feat: Enhance responsive design and layout adjustments for mobile devices (#13739) 2026-09-08 14:20:16 +08:00
ssongliu 8eac9a1808 fix(container): preserve IP allocation across container operations (#13738) 2026-09-08 14:20:03 +08:00
CityFun ce74d96617 feat: Add support for importing environment variables into the runtime environment. (#13737) 2026-09-08 09:52:40 +08:00
ssongliu a15e77d605 fix: harden terminal session lifecycle (#13736) 2026-09-08 09:29:01 +08:00
CityFun bad022f524 feat: Add support for switching image versions in runtime environments. (#13730) 2026-09-07 18:30:27 +08:00
CityFun 50a54d0613 feat: Add support for importing environment variables into the runtime environment. (#13727) 2026-09-07 18:26:35 +08:00
蘭 a47e41a8b7 feat: enhance download management with improved error handling and status tracking (#13734) 2026-09-07 18:25:15 +08:00
CityFun f938443e55 fix: issue self-signed certificates using the selected CA (#13728) 2026-09-07 18:06:20 +08:00
蘭 b90abd2b28 feat: enhance ZIP entry normalization and path compatibility checks (#13733) 2026-09-07 18:06:11 +08:00
ssongliu da5682a600 fix(firewall): harden port switching and rule synchronization (#13731) 2026-09-07 18:05:31 +08:00
HynoR 81b72d9b7d feat: Implement server-side SSH session persistence and recovery (#13707)
* feat(terminal): keep ssh sessions alive server-side with reattach

Split the terminal ws handling into a Session (pty + ssh backend) and an
Attachment (one websocket). A session outlives its websocket: a clean close
(1000) ends the pty, any other disconnect keeps it for a 30-minute grace
period and it can be reattached via `?session=<id>`. Output goes through a
fixed 128KB ring buffer so a reattaching client gets the recent tail, with a
truncation marker if it fell behind. Sessions are owner-scoped; a second
attachment kicks the first (4409), unknown ids get 4404.

New endpoints under /hosts/terminal/sessions (search, close) let the
frontend list and recover sessions after a tab or browser is closed.

* feat(terminal): floating terminal dock with session recovery

Terminals now live in a layout-level host and are teleported into whichever
view shows them, so leaving the terminal page no longer kills them. A dock
handle on the right edge opens a non-modal dialog from any page with every
live session, a picker for local shell / ssh hosts, minimize, and
close-all. On page load the store recovers sessions the server still holds,
so an accidentally closed tab or browser can resume within the grace period.
The menu-tab label shows the live session count.

* fix(terminal): page re-claims its slots under a locked menu tab

With the terminal menu tab locked (keep-alive), leaving the page deactivates
it instead of unmounting it, so the slot ref callback never re-runs on
return. After the dock had taken the Terminal over and released it, nobody
claimed it for the page again and it stayed parked in the hidden host.

Claim/release slots explicitly on mount, activated, deactivated and unmount,
the same ownership rule the dock uses, instead of relying on the ref callback.

* fix(terminal): logout closes every kept-alive terminal session

A logged-out panel has nobody watching it, so nothing it left running should
survive: core now tells the local agent to close all terminal sessions when the
user logs out, changes the password, or changes the bind domain. Until now the
teardown relied on the logging-out tab sending close code 1000; a second tab or
a websocket held outside the SPA kept its shell after logout.

Agent: terminal.CloseAll and POST /hosts/terminal/sessions/closeAll.
Core: LogOut / deleteCurrentSession / BindDomain call it via proxy_local,
best effort.

* fix(terminal): pin a local shell to the node it was opened on

The node a local shell connects to was resolved from the current node every
time the websocket was built, so after switching nodes a reconnect carried the
old session id to the new node (4404) and then opened a shell there instead.
Store the operateNode on the entry when it is created; ssh shells keep going to
the master. Shells on a non-master node get the node name in their title so a
restore in another node's view can tell them apart.
2026-09-07 15:01:35 +08:00
CityFun 6e13143286 feat: VllM add support for GB10 Deepseek V4 Flash Vision Exp (#13725) 2026-09-07 14:56:56 +08:00
Snrat 70fc628c81 feat(openresty): activate brotli when the module is enabled, and fix gzip defaults (#13639)
* feat(openresty): manage http-context directives via conf/http.d

Add a managed-file mechanism for http-context nginx directives, mirroring
the existing one for conf/modules-enabled.

A separate directory is required because load_module is a main-context
directive, so modules-enabled is included at the top level of nginx.conf and
cannot host http-context directives.

Files carry a 1panel-http- prefix; anything else in the directory is left
untouched. Writes are atomic via a temporary file plus rename, and the
directory is snapshotted so a failed nginx -t can be rolled back.

The mechanism is inert when conf/http.d does not exist, which is the case
for OpenResty installations predating the directory.

* fix(openresty): correct gzip defaults and add missing compressible types

Bring the embedded gzip template in line with how sites are actually served.
It was previously dead code: nothing referenced gzip.conf, so the values
never reached an installation. It is now embedded and used by the migration
that follows.

gzip_types was missing application/json, so JSON API responses were served
uncompressed. Also add ld+json, text/xml, xhtml+xml, rss+xml, atom+xml,
wasm, svg+xml and ttf/otf. Already compressed formats (images, woff2,
archives) stay out on purpose.

gzip_comp_level 6 -> 5, at the cost/ratio knee for gzip.

gzip_proxied any, so that proxied responses are compressed regardless of
their Cache-Control semantics.

gzip_static is intentionally not enabled: nginx does not verify that a .gz
file is newer than its source, so a stale artifact would be served
indefinitely with no error.

* feat(openresty): activate brotli directives when the module is enabled

Enabling ngx_brotli only emitted load_module, leaving the module loaded but
inert: no response was ever brotli-encoded until the user added
`brotli on` and `brotli_types` to nginx.conf by hand. The module is
prebuilt into the OpenResty image and listed in the catalog, so the only
missing step was the runtime configuration.

Enabling the module now also writes its http-context directives to
conf/http.d, and disabling or deleting it removes them. Removal matters:
leaving `brotli on` behind after the .so is unloaded makes nginx fail to
start on an unknown directive.

Both directory sets are written before nginx -t runs, so nginx only ever
observes a consistent state, and a failed check rolls back load_module
files and runtime directives together.

Runtime defaults are declared per module in a table, so other modules
needing http-context configuration can be added without touching the
reconcile logic.

brotli_types matches gzip_types so both encoders cover the same content.
brotli_comp_level is 5 rather than the nginx default of 6: level 5 reaches
roughly gzip level 9 ratio at a fraction of the cost, while 6 is tuned for
static assets and is too expensive for dynamic responses.

brotli_static is deliberately omitted, for the same reason gzip_static is:
nginx does not verify that a precompressed artifact is newer than its
source, so a stale file would be served indefinitely with no error.

Installations without conf/http.d keep the previous behaviour instead of
failing.

* feat(openresty): refresh stock gzip defaults on upgrade

Upgrades deliberately preserve the user's nginx.conf, so corrected gzip
defaults shipped with a new OpenResty version never reach existing
installations. Rewrite the values in place during upgrade, but only when the
block is provably untouched.

The rewrite requires every gzip directive to match the factory values byte
for byte, with none missing, none added and none duplicated. Any deviation
means the user tuned compression, and their configuration is left alone.

gzip stays in the http block of nginx.conf rather than moving to an included
file: nginx rejects a duplicate gzip directive across contexts, and the
compression settings page reads and writes these same keys in nginx.conf, so
a relocated block would be reintroduced on the next save and break nginx -t.

The config parser is not used either. Its dumper regenerates the whole file,
drops standalone comments and reorders proxy includes, which would be
destructive on a user's main config. Lines are edited individually so
everything outside the gzip block stays byte-identical.

The rewrite is idempotent, and a failed nginx -t restores the previous file.
A failure is logged as a warning instead of failing the upgrade.

* fix(website): preserve size units in nginx performance settings

The form stripped the unit suffix when reading a directive and then always
appended a fixed one when saving, so the unit was silently reinterpreted.

A config carrying `gzip_min_length 512;`, meaning 512 bytes, was read as 512
and written back as `512k`, inflating the threshold by 1024 and effectively
disabling compression for every response under 512 KB. The same applied to
client_header_buffer_size and client_max_body_size, where the value grew by
a factor of 1024 in the opposite, riskier direction.

Remember the unit that was read and write it back unchanged, defaulting to
the previous suffix only when the directive carries no unit information. The
input suffix now shows the unit actually in use instead of a hardcoded
label.

Also fix the value parsing itself: `Number(value.match(/\d+/g))` coerces a
multi-number match to NaN, so a directive such as `gzip_buffers 4 16k` would
blank the field. Take the first captured number instead.

* feat(website): expose brotli settings in the compression page

Brotli could be enabled as a module but never configured from the panel, so
its behaviour was invisible and unchangeable without editing nginx.conf by
hand.

The section appears only once the module is enabled and built, since the
directives are rejected by nginx while the module is not loaded. Values are
read from and written to the panel-managed http.d file rather than
nginx.conf, so they are removed together with the module.

brotli_types stays out of the form on purpose: it is kept aligned with
gzip_types so both encoders cover the same content, and exposing it would
invite the two lists to drift apart.

Saving reuses the existing scope endpoint with a dedicated brotli scope,
which keeps the managed file as the single source of truth instead of
duplicating the values into nginx.conf.

* fix(openresty): stop a stale build option from forcing a full rebuild

Manual builds and upgrades disagreed on when a full OpenResty image rebuild
is required. `executeNginxModuleBuild` used `staticNginxBuildRequired`, which
also treated a non-empty `RESTY_CONFIG_OPTIONS_MORE` in .env as a reason to
rebuild, while `buildNginx` looked only at the module list.

The env value is derived state, not an input: `configureStaticNginxModules`
rewrites it from the current module list, and every build path calls that
function before building. With no static module enabled it writes an empty
string, so the rebuild the latch triggered ran with an empty option list and
could only reproduce the image it started from — up to 120 minutes of build
time to arrive back where it began.

Decide on the module list alone, which is what the upgrade path already did.

An install that genuinely has an enabled static module is unaffected: both
predicates already agreed in that case. Leftover values are still cleared, by
`configureStaticNginxModules` on the next build or upgrade.

* feat(openresty): build modules on versions without a dynamic builder

Module state written before build modes existed carries no buildMode.
validateNginxModuleBuildMode rejects the empty value, which fails
loadNginxModules and with it every module operation and the upgrade itself —
the whole module subsystem, not just the static feature.

Infer the missing value from what the install can actually do instead:
dynamic when the builder and catalog are present, static when the compose
file still has a build section and build/Dockerfile to recompile the image.

Builds follow the same principle. Asking a pre-dynamic install to build a
module used to return "the installed OpenResty version does not support
dynamic module builds", which is a dead end: these versions produce modules
by compiling them into the image, and they still can. Such a build is now
retargeted to the static path, with --add-dynamic-module rewritten back to
--add-module and =dynamic switches reduced to their plain form. The error is
kept only for installs that reference a prebuilt image and genuinely cannot
compile anything, and it now says so and points at the upgrade.

The retarget applies to a copy that drives one build and is never persisted,
so the catalog stays authoritative and modules return to dynamic once the
install gains a builder.

Verified end to end against 1.27.1.2-5-1-focal, which ships no
Dockerfile.modules and no module.catalog.json: ngx_brotli compiles into the
image, nginx -t accepts the brotli directives with no load_module present,
and the server responds with Content-Encoding: br.

* feat(openresty): respect a hand-written brotli configuration

A user who enabled brotli before the panel managed it did so by editing their
configuration by hand. Emitting a managed file alongside it defined every
directive twice and nginx refused to start, so these users — the very ones
this feature is for — broke on upgrade.

Detection now scans every file nginx loads brotli from: nginx.conf and the
conf.d and default includes. Any active brotli* directive counts, so a lone
tuning directive is enough to treat the module as user-managed, and a
commented-out line never triggers it.

When the user owns the configuration, the panel stays out of the way:

- No managed http.d file is written, so the user's definition stays the only
  one and their values are never overridden.
- brotli_types diverging from gzip_types is left exactly as written; the panel
  does not widen them.
- The settings page reports their real values and shows a notice that brotli
  is managed manually, rather than presenting defaults that do not match the
  running configuration.
- Saving edits their own lines in place, keeping indentation and comments,
  instead of writing a second copy. The flag is localised in all 12 languages.

Detection re-runs on every reconcile, so once the user deletes their
hand-written config the panel takes over again automatically.

* feat(openresty): wire conf/http.d from the agent instead of upgrade scripts

Following review feedback: setup scripts no longer create conf/http.d or
inject its include into existing installations' nginx.conf. The agent owns
the directory, the include, the runtime directives and the rollback, and only
touches nginx.conf when a module that needs http-context configuration is
actually enabled.

Insertion is a line-level edit, never the config parser: the include lands
before the conf.d include, or at the top of the http block when that anchor
is absent, keeping the surrounding indentation and leaving the rest of the
file byte-identical. A config without a locatable http block degrades to the
previous behaviour — module loads, runtime directives skipped, warning logged
— instead of failing the operation. Detection re-runs on every reconcile, so
an install recovers on its own once nginx.conf can be edited again.

Rollback now covers three artefacts: modules-enabled, http.d, and the
inserted line in nginx.conf.

The include is kept when the last module is disabled. Pointing at an empty
directory is harmless, and removing it would mean another edit of the user's
main config with its own failure surface.

When the include is missing and cannot be inserted, the brotli settings
report ManagedUnavailable and the settings page warns that the values shown
will not take effect, instead of presenting inert settings as live.

* fix(openresty): tighten brotli ownership handling and build guards

The settings page could not save brotli values for users who wrote their own
directives after the panel had started managing the module: the stale managed
file was still on disk, so every save ended in a duplicate directive error.
That file is now removed before the in-place edit, and a failed nginx -t
rolls back both sides.

User-managed detection now also covers conf/default, which is included at
http scope like conf.d, and the http.d include check no longer depends on the
exact container path literal, so an include written in a slightly different
form is recognised instead of duplicated.

The dynamic-to-static build fallback is dropped. The catalog and the dynamic
builder ship together, and installs without the catalog fail to load their
module state earlier anyway, so the branch could never run; what remains is
an error that says the version cannot build modules and to upgrade first.

The embedded gzip template is no longer wired to an unused variable, and a
test keeps it in sync with the defaults the upgrade writes.

Smaller fixes in the same area: a custom module named ngx_brotli no longer
inherits the built-in runtime defaults; nginx.conf edits go through temp file
renames and inserted lines follow the file's own line endings; the gzip
rewrite keeps each line's own indentation; the settings page resets its unit
cache on load, warns when the brotli half of a save fails after gzip already
applied, and no longer coerces unrendered keys to zero.

* fix(openresty): prove brotli reached the running server, not just disk

nginx -t and a successful reload both pass even when the managed directory
never reaches the container: the include is a glob, so a missing bind mount
or an unrecognised include variant silently loads nothing. The brotli save
now reads the effective configuration back with nginx -T and rolls the write
back with an actionable error when the directives are not there, instead of
reporting success for settings nobody will ever see.

The include match also accepts the quoted form nginx permits, so a
hand-written or legacy variant no longer invites a second include of the same
directory.

Values written into nginx.conf are checked against a whitelist before any
file is touched. The UI only ever sends on/off, numbers and sizes, but the
endpoint is reachable directly, and an unfiltered value could inject a
directive or trip the group-reference expansion of regexp.ReplaceAllString in
the in-place rewrite.
2026-09-07 14:42:03 +08:00
Eric Curtin 9858881ce6 feat(ai): add llmman as a local model provider (#13717)
llmman (https://github.com/llmmanorg/llmman) is a local model runner
serving Ollama- and OpenAI-compatible routes on 127.0.0.1:17434.
Register it in the agent provider catalog next to Ollama and extend
every Ollama special case (no API key, verification skipped, OpenClaw
placeholder key, manual initial model) to cover it as well.
2026-09-07 11:41:13 +08:00
A_Words eb6a8c7646 fix: avoid website SSL port conflicts on creation (#13720) 2026-09-07 11:39:15 +08:00
ssongliu a71aea8aec fix(firewall): hide inactive Docker ports (#13716) 2026-09-04 21:43:05 +08:00
ssongliu 918c441f88 Revert "fix(fail2ban): treat process as active when client ping succeeds (#13…" (#13715)
This reverts commit a6e2efa6c9.
2026-09-04 16:46:47 +08:00
CityFun 960b4b0345 fix(b.ai): resolve account validation failure (#13712) 2026-09-04 16:40:48 +08:00
蘭 3aa4bfaa82 ref: streamline SSH login log handling and remove unused functions (#13705) 2026-09-03 18:33:07 +08:00
ssongliu b3bdf9ef7e fix: validate cronjob timeout as positive integer (#13706) 2026-09-03 18:26:17 +08:00
ssongliu 2948b8ffe8 fix: normalize Docker firewall rule sync (#13704) 2026-09-03 18:22:48 +08:00
ssongliu e99c6c08a5 feat: support menu tab session keep-alive (#13698) 2026-09-03 15:31:25 +08:00
ssongliu 6fb389b2ed fix(container): extend disk usage stats timeout (#13695) 2026-09-03 15:31:12 +08:00
ssongliu c09833cbde chore(deps): batch safe and security dependency updates (#13696)
* chore(deps): batch safe dependency updates

* chore(deps): address dependency alerts
2026-09-03 11:46:56 +08:00
ssongliu fa2ad69154 feat: improve community restore package guidance (#13694) 2026-09-03 09:45:40 +08:00
ssongliu 51d84455a3 fix(container): avoid pinning dynamic IPs on upgrade (#13693) 2026-09-02 17:23:00 +08:00
ssongliu d88d98d8a8 fix: warn on node version mismatch after login (#13691) 2026-09-02 14:57:10 +08:00
蘭 5aec466c8e feat: add support for custom webhook configuration (#13685) 2026-09-02 14:49:20 +08:00
ssongliu 0ee93774d5 fix(container): authenticate private registry image repulls (#13690) 2026-09-02 14:49:09 +08:00
ssongliu 7be7368bb9 fix: improve firewall lifecycle recovery (#13686) 2026-09-02 14:48:59 +08:00
ssongliu eab0bb4a94 fix: repair firewall forwarding migration (#13689) 2026-09-02 14:48:47 +08:00
9f74f2077a Fix/ssh disconnect session key (#13669)
* fix(ssh): correlate disconnect logs by client endpoint

* fix(ssh): scope endpoint correlation to active sessions

---------

Co-authored-by: JayLee-sre <1.18655426e+08+JayLee-sre@users.noreply.github.com>
Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-09-02 09:32:07 +08:00
zhudaguanrenandzhudaguaneren a6e2efa6c9 fix(fail2ban): treat process as active when client ping succeeds (#13679)
Fail2ban UI currently keys isActive only on systemd fail2ban.service.
If the daemon is alive under another process manager, whitelist and
blacklist stay disabled. Detect liveness with fail2ban-client ping.

Fixes #13678

Co-authored-by: zhudaguaneren <218366267+zhudaguaneren@users.noreply.github.com>
2026-09-01 18:15:32 +08:00
igophper 205ef3009a fix: deduplicate port bindings in container port mapping (#13663) 2026-09-01 18:05:07 +08:00
蘭 d7edbd1e95 feat: reconcile hide menu integrity (#13681) 2026-09-01 17:52:12 +08:00
蘭 b361f464c5 fix: enhance upload handling and disable actions during processing (#13676) 2026-09-01 17:52:03 +08:00
ssongliu fb377d2e99 fix(firewall): recover rules after upgrade (#13680) 2026-09-01 14:53:01 +08:00
ssongliu deddd392ba fix(firewall): handle Docker host input ports (#13675) 2026-09-01 09:33:29 +08:00
ssongliu 3ab10848c8 fix: restore firewall-dependent rules after reset (#13674) 2026-08-31 17:15:56 +08:00
ssongliu 433f1a940f fix: improve firewall abnormal state diagnostics (#13673) 2026-08-31 16:18:13 +08:00
ssongliu 1f12c09eb5 fix: improve firewall rule management (#13670) 2026-08-31 11:48:14 +08:00
ssongliu 31e6d523f9 fix: improve firewall runtime rule handling (#13667) 2026-08-31 09:28:15 +08:00
CityFun 3c0bd051bf feat: custom model account dashscope-images (#13664) 2026-08-28 18:22:52 +08:00
ssongliu 3c2d92dc5f fix: improve firewall backend rule handling (#13662) 2026-08-28 16:11:11 +08:00
ssongliu 262bd14bc8 chore(deps): batch dependency updates (#13650) 2026-08-28 09:56:01 +08:00
ssongliu f15ff46e34 fix: improve firewall rule management (#13648) 2026-08-27 22:24:15 +08:00
CityFun fc1ec4e1b0 feat: add gb10 vllm image support (#13647)
* feat: add gb10 vllm image support

* feat: add gb10 vllm image support
2026-08-27 18:36:37 +08:00
ssongliu 53f75826d8 refactor: simplify firewall service structure (#13646) 2026-08-27 14:00:43 +08:00
ssongliu ddfb816ef1 feat: improve firewall backend synchronization (#13645) 2026-08-27 10:37:09 +08:00
ssongliu 18428d108e feat: improve firewall backend synchronization (#13644) 2026-08-27 10:31:39 +08:00
蘭 3506c5dd3b feat: add footer navigation component and update translations (#13642) 2026-08-26 14:30:45 +08:00
ssongliu 2dffd06b1b chore: clarify agent Skill directory labels (#13640) 2026-08-26 14:30:34 +08:00
ssongliu 12f2484d12 feat: support firewall rule synchronization (#13637) 2026-08-25 18:50:27 +08:00
蘭 2dea44acf6 fix: prevent rate limit bypass in public file shares (#13632) 2026-08-24 21:50:48 +08:00
ssongliu 205f76c65d fix: update vulnerable dependencies (#13629) 2026-08-24 18:04:14 +08:00
ssongliu 86af4fbd4d feat: improve firewall status and UI translations (#13630) 2026-08-24 17:40:36 +08:00
ssongliu 7915230121 refactor: rebuild firewall management (#13628)
* refactor(firewall): rebuild rule management foundation

* refactor(firewall): streamline rule checks and inventory

* feat(firewall): improve native rule inventory

* refactor(firewall): refine rule management

* feat: add Docker port guard

* feat(firewall): support native nftables

* feat(firewall): add configurable firewall selection

* feat(firewall): support nftables docker port guard

* refactor(firewall): complete v2 rule management and migration

* refactor(firewall): align state and API contracts

* refactor(firewall): unify rule management operations

* feat: refine firewall v2 rules and forwarding

* fix(firewall): harden dual-stack rule management

* refactor(firewall): consolidate rule validation and persistence
2026-08-24 12:51:34 +08:00
ssongliu 1b27db7daa fix: honor configured ClamAV scan timeout (#13619) 2026-08-21 17:33:52 +08:00
ssongliu 7370dcaa55 fix(container): log startup failure before rollback (#13618) 2026-08-21 17:33:40 +08:00
ssongliu 6a378b6863 fix: improve enterprise license page compatibility (#13616) 2026-08-21 17:33:23 +08:00
ssongliu 6f6747a584 fix: support trusted proxies for allowed IPs (#13608) 2026-08-21 17:33:14 +08:00
蘭 825221b2bb ref: Optimize mobile editor (#13607) 2026-08-20 18:34:20 +08:00
ssongliu 1e9d4b592e fix: preserve failed compose deployments (#13603) 2026-08-20 18:28:51 +08:00
蘭 4a51db4764 fix: Fix the issue of menu loss caused by switching service regions (#13602)
* fix: Fix the issue of menu loss caused by switching service regions

* fix: Fix the issue of menu loss caused by switching service regions
2026-08-20 18:28:41 +08:00
ssongliu afea71c81c fix: sync system version on startup (#13601) 2026-08-20 16:06:12 +08:00
ssongliu 9c8ca2ab3c fix: center community restore dialog (#13600) 2026-08-20 15:34:17 +08:00
ssongliu a04875f64b fix: sync documentation source settings (#13599) 2026-08-20 15:01:27 +08:00
ssongliu 7ec0bdb3f7 feat: support multi-chip Ascend devices (#13593) 2026-08-20 15:01:15 +08:00
CityFun d2dbb6486e feat: add enterprise demo handle (#13592)
* feat: add enterprise demo handle

* feat: add enterprise demo handle
2026-08-20 15:01:04 +08:00
14728f889e fix(ssh): correlate disconnect logs by client endpoint (#13581)
* fix(ssh): correlate disconnect logs by client endpoint

* fix(ssh): scope endpoint correlation to active sessions

---------

Co-authored-by: JayLee-sre <1.18655426e+08+JayLee-sre@users.noreply.github.com>
Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-08-19 10:20:16 +08:00
CityFun ff199245b0 feat: add some translate (#13585) 2026-08-18 18:46:27 +08:00
蘭 667807e249 fix: Fix large file/slow network upload timeout in file management (#13584) 2026-08-18 18:12:42 +08:00
Jet.andJayLee-sre 63e09c8c47 docs: name Halo in website deployment overview (#13580)
Co-authored-by: JayLee-sre <1.18655426e+08+JayLee-sre@users.noreply.github.com>
2026-08-18 09:49:11 +08:00
ssongliu 17a8835d59 feat: support Ascend 910B GPU monitoring (#13579)
* feat: support Ascend 910B GPU monitoring

* chore: remove GPU test files
2026-08-17 17:38:43 +08:00
王贺 b306bfa77a fix: correct disk usage for device aliases (#13570) 2026-08-17 15:20:15 +08:00
蘭 b1eff2a893 feat: change some translate (#13568) 2026-08-17 13:58:53 +08:00
王贺 5ac7c80881 fix: support underscores and hyphens in website default documents (#13551) 2026-08-14 10:55:44 +08:00
CityFun d402f67fc3 feat: Optimize application upgrade logic (#13549)
* feat: Optimize application upgrade logic

* feat: Optimize application upgrade logic

* feat: Optimize application upgrade logic
2026-08-13 18:28:59 +08:00
Chen, Ting-An c13793c445 fix(i18n): polish Traditional Chinese agent copy (#13536) 2026-08-12 15:32:39 +08:00
CityFun daa3f6b206 chore: Update dependencies (#13528) 2026-08-12 15:29:40 +08:00
maninhill a2d85c911d Revise user statistics and AI agents limit in README (#13525)
Updated user statistics and modified AI agents limit in the feature table.
2026-08-11 09:39:10 +08:00
蘭 1b76c91e1b fix: Fix file loss issues when copying or moving large directories (#13524) 2026-08-10 22:07:45 +08:00
maninhill d663a4397a Update README for clarity and security features (#13516)
Removed redundant mention of AI gateway in the Full-Stack AI Management section and added WAF to the security features.
2026-08-10 10:04:41 +08:00
maninhill d1558c5eae Revise README for clarity and feature updates (#13515)
Updated the README to enhance the description of 1Panel's features, including AI management, security, and backup capabilities. Adjusted the Pro Edition feature comparison to include the Enterprise edition.
2026-08-09 09:14:52 +08:00
maninhill 0da4f77a2e Revise README.zh-Hans.md for feature updates (#13512)
Updated the README in Chinese to reflect new features and improvements in 1Panel, including AI management capabilities and enhanced security features.
2026-08-07 22:51:57 +08:00
ssongliu e7ef35740c fix: align compose project name handling (#13500) 2026-08-07 12:44:29 +08:00
ssongliu b0d561e33b feat: show license expiration alert on dashboard (#13499) 2026-08-07 10:22:23 +08:00
CityFun 75b362fa9b chore: update dependencies (#13497) 2026-08-06 21:47:20 +08:00
蘭 466f373ef6 feat: change some translate (#13496) 2026-08-06 18:27:27 +08:00
ssongliu 4489641b54 perf(snapshot): hard link local recovery archive (#13494) 2026-08-06 16:08:58 +08:00
蘭 1971d9dec2 fix: handle external login state and emit readiness event (#13493)
* fix: handle external login state and emit readiness event

* fix: handle external login state and improve SAML2 logout response handling
2026-08-06 13:53:36 +08:00
CityFun c38d741770 fix: Fix an issue where the website monitoring directory was not completely removed when deleting a website. (#13492) 2026-08-06 13:52:53 +08:00
蘭 122a474032 feat: enhance alert log search with start and end time filters (#13489) 2026-08-05 21:13:25 +08:00
CityFun 54854e99e7 feat: change deepseek api url (#13487) 2026-08-05 17:10:34 +08:00
CityFun e01fb7c905 fix: Fixed bug with website template (#13484) 2026-08-05 16:56:08 +08:00
CityFun 83a3675a0c feat: QwenPaw support config username/password (#13478) 2026-08-05 16:35:09 +08:00
ssongliu 9dfa451fae fix: adjust host column display (#13477) 2026-08-05 13:52:33 +08:00
ssongliu 9204a287fd fix: improve community restore loading state (#13475)
* fix: improve community restore loading state

* fix: resume license page initialization after restore
2026-08-05 13:52:14 +08:00
f027507f9a fix: preserve active cronjob records during cleanup (#13474)
* fix: preserve active cronjob records during cleanup

* fix: delete cronjob records before removing files

Co-authored-by: ssongliu <73214554+ssongliu@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-08-05 11:32:20 +08:00
ssongliu 01aee89f3b fix: correct process start time in LXC (#13473)
* fix: correct process start time in LXC

* fix: reject unresolved process start times
2026-08-05 11:22:26 +08:00
CityFun 17285d4397 fix: Fixed issue with upgrade openresty failed (#13470)
* fix: Fixed issue with upgrade openresty failed

* fix: Fixed issue with upgrade openresty failed
2026-08-05 09:37:02 +08:00
蘭 91ae846418 feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation (#13458)
* feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation

* feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation

* feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation
2026-08-04 17:34:50 +08:00
ssongliu 1eb429631d fix: resolve compose project names consistently (#13468) 2026-08-04 17:17:07 +08:00
CityFun 6584e3b868 chore: update dependencies (#13467) 2026-08-04 16:46:27 +08:00
ssongliu 14e2294db9 feat: refine node health check settings (#13463) 2026-08-04 15:09:34 +08:00
ssongliu 26b69bc208 fix: bypass cached route in user info navigation (#13462)
* fix: bypass cached route in user info navigation

* fix: avoid caching entrance route
2026-08-04 15:09:17 +08:00
CityFun 2111d4c16b style: change table page config (#13456) 2026-08-04 10:45:13 +08:00
蘭 b682835b4e fix: Implement file sharing password processing and remote download file name processing (#13452) 2026-08-04 09:37:26 +08:00
蘭 c34ac2f31e feat: change some translate (#13451) 2026-08-04 09:37:08 +08:00
CityFun c28047374a feat: Fix the issue where website configuration files fail to restore from backups. (#13445) 2026-08-03 15:26:20 +08:00
ssongliu 60d16609f7 fix: clarify API trusted proxy placeholder (#13444) 2026-08-03 14:30:50 +08:00
CityFun 02ca9347dc feat: Update Xiaomi Models (#13443) 2026-08-03 14:28:26 +08:00
ssongliu d0187994ee fix: ignore empty directories in device clean scan (#13442)
* fix: ignore empty directories in device clean scan

* fix: skip zero-size device clean entries
2026-08-03 14:06:56 +08:00
蘭 be672d604f feat:Support logging in with oidc and saml2 (#13441)
* feat(auth): implement OIDC authentication endpoints and error handling

* feat(auth): add OIDC provider discovery endpoint and related functionality

* feat(auth): add SAML2 authentication support and related functionality

* feat(auth): add LDAP authentication support and related functionality

* fix(auth): improve login keydown handler for better event handling
2026-08-03 13:43:01 +08:00
CityFun 16e3d496eb Merge branch 'dev-v2' into pr@dev-v2@common (#13436) 2026-08-01 14:08:03 +08:00
ssongliu 9159ab842d feat: support filtering app store composes (#13432)
* feat: support filtering app store composes

* fix: initialize app store filter during setup
2026-07-31 18:27:10 +08:00
ssongliu 0d8835d494 fix: constrain dashboard column height (#13433) 2026-07-31 18:26:02 +08:00
ssongliu 9f9e3aacfc feat: add Community Edition restore UI (#13431) 2026-07-31 18:15:24 +08:00
CityFun 7bd11fe73e feat: Keep Website List Order Stable After Editing (#13424) 2026-07-31 11:05:23 +08:00
BugPleaseGoandCityFun e11dc5fadd Add Website Template (#13400)
* feat: Add Website Template

* fix: Don't display the template list

* feat: Add Mcp TopList

* docs: Remove Mcp TopList

* Add more languages

---------

Co-authored-by: CityFun <31820853+zhengkunwang223@users.noreply.github.com>
2026-07-31 10:01:20 +08:00
HynoR f35b0deb29 refactor(firewall): extract port forwarding subsystem (#13347)
Port forwarding no longer shares the filter client. FilterClient keeps only
filter capabilities, and forwarding gets its own adapter, service and boot
replay:

- utils/firewall/forwarding holds the provider adapters. firewalld uses native
  forward-port, ufw and iptables share the NAT implementation moved out of
  client/iptables/forward.go.
- service/forwarding.go owns base info, search, operate, enable and replay.
  The API keeps its routes and dispatches on name/type/operate.
- init/firewall replays forwarding through that service instead of loading NAT
  rule files inline.

Also adds 1PANEL_FORWARD to the IptablesOp name enum: the frontend already
sends {"name":"1PANEL_FORWARD","operate":"init-forward"} and the validator
rejected it with 400 before reaching the service. Besides that, the only
observable difference is that a forward-tab search no longer triggers the
port/address record cleanup goroutine on the side.
2026-07-30 14:07:41 +08:00
ssongliu 33b3eecb95 feat: unify pin actions (#13417) 2026-07-30 09:39:47 +08:00
CityFun 6ac7a5f167 feat: Optimize the application upgrade logic. (#13416) 2026-07-29 17:44:50 +08:00
CityFun a5fbbfc460 feat: Optimize the application upgrade logic. (#13415) 2026-07-29 17:19:52 +08:00
ssongliu 563df3da71 fix: support trusted proxies for API allowlist (#13409) 2026-07-29 16:48:59 +08:00
ssongliu 13e6bc4fac feat: separate website and standalone FTP identities (#13412) 2026-07-29 16:41:43 +08:00
ssongliu 357d77856a fix: align dashboard uptime with boot time (#13410)
* fix: align dashboard uptime with boot time

* fix: handle invalid dashboard boot time
2026-07-29 16:36:05 +08:00
ssongliu 4279339189 fix: simplify cronjob record duration display (#13398) 2026-07-29 10:57:40 +08:00
ssongliuandCopilot Autofix powered by AI 380033dfe0 fix: support MySQL backup GTID options (#13407)
* fix: support MySQL backup GTID options

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-29 10:42:21 +08:00
ssongliu 97d383ed12 feat: add explicit FTP identity initialization (#13390) 2026-07-28 17:07:57 +08:00
ssongliu 52e6a63ebc fix: improve host system log pagination (#13395) 2026-07-28 17:07:22 +08:00
CityFun 7506e709e2 Add plugin management support to OpenClaw. (#13388) 2026-07-28 14:20:55 +08:00
蘭 cf37de66fc feat(auth): add LDAP authentication support and related configurations (#13385)
* feat(auth): add LDAP authentication support and related configurations

* feat(ldap): implement LDAP synchronization features and enhance user import logic

* feat(auth): add authSource and authSourceStatus to user information

* feat(i18n): update LDAP synchronization error messages in multiple languages

* feat(i18n): update LDAP synchronization error messages in multiple languages

* feat(i18n): update LDAP synchronization error messages in multiple languages
2026-07-28 14:20:42 +08:00
CityFun e2754b447d feat: Add support for text-to-image APIs. (#13380)
* feat: Add support for text-to-image APIs.

* feat: Add support for text-to-image APIs.
2026-07-28 09:42:23 +08:00
ssongliu c04848a92a feat: sync script library on startup (#13376) 2026-07-27 15:10:09 +08:00
ssongliu 748f5f070a fix: show placeholder for empty host log service (#13375) 2026-07-27 13:56:24 +08:00
蘭 2dcb367622 fix: Fix quick data selection errors with Shift (#13374)
* fix: Fix quick data selection errors with Shift

* fix: Fix quick data selection errors with Shift
2026-07-27 13:48:32 +08:00
ssongliu 26bd2d08be fix: improve host system log filtering compatibility (#13373) 2026-07-27 11:25:26 +08:00
ssongliu 739686ff89 fix: avoid decimal time unit conversions (#13372) 2026-07-27 10:50:42 +08:00
ssongliu 68a871a5d7 fix: restore fail2ban after firewall start (#13364) 2026-07-27 10:16:04 +08:00
ssongliu b47bba4827 feat: add VM health check translations (#13361) 2026-07-24 16:00:29 +08:00
CityFun 3b91859a49 fix: Fix an issue where an application could still be installed after its version had been deleted remotely. (#13360) 2026-07-24 16:00:02 +08:00
CityFun c417bc9c2b chore: change lego sdk (#13357) 2026-07-23 18:37:36 +08:00
CityFun 7c95becfeb feat: Optimize the OpenResty module compilation logic. (#13355)
* refactor: separate openresty module catalog and state

* feat: Optimize the OpenResty module compilation logic.
2026-07-23 16:53:56 +08:00
ssongliu 6b1480c5fa feat: move container batch operations to table footer (#13348) 2026-07-23 15:45:53 +08:00
ssongliu 7f450544e1 feat: improve host log management (#13345) 2026-07-23 14:53:26 +08:00
ssongliu 0c046edfe5 fix(database): improve MySQL user display (#13344) 2026-07-23 14:44:07 +08:00
Snrat 2a2e6607b8 feat: 重构OpenResty模块部分以支持动态编译 (#13291)
* feat: support dynamic module build for OpenResty

* feat: add dynamic module build page for OpenResty

* refactor: drop auto fallback, gate dynamic build by version support

- remove auto-to-static fallback; dynamic build failure now reports the
  error and hints switching to static build manually
- gate dynamic builds on module support files (Dockerfile.modules +
  module.catalog.json) instead of version numbers, expose
  dynamicSupported in the modules API
- collect repeated path/status/operate strings into constants
- move nginx module regex patterns into utils/re with semantic helpers
- reorganize nginx_module.go around the main build flows and inline
  single-use thin helpers

* feat: limit nginx module build mode options by version support

- build mode radio offers only dynamic and static (auto maps to dynamic
  for legacy data)
- disable the dynamic option with a hint when the installed OpenResty
  version lacks dynamic build support

* feat: complete i18n for nginx module pages

Fill in the new nginx module keys for all eleven language files
(translations other than zh/en are draft machine translations).

* feat: probe dynamic module support on load and drop the auto build mode

- probe each non-static module's configure params when loading the
  module list and report dynamicSupport=supported/unsupported up front
- normalize the legacy auto build mode to dynamic

* feat: clarify module build modes in the UI

- build drawer lists dynamic modules (tagged, hot-reload) and static
  modules (tagged, full rebuild + container restart) separately
- disable the dynamic option per module when its params do not support
  dynamic build, distinct from the version gate hint
- drop the auto build mode wording everywhere and sync all eleven
  language files

* feat: clarify purpose of the nginx module build drawer

- add a purpose hint explaining dynamic (hot reload) vs static (full
  rebuild + container restart)
- drop the per-module mode tags now that section headers carry the
  semantics
- allow submitting with zero dynamic modules selected when static
  modules are present, so static-only users can trigger a build

* feat: pass apt mirror through to dynamic module builds

The mirror selected in the build dialog (or CONTAINER_PACKAGE_URL in the
app env as fallback) is now forwarded as a build arg so the module
builder uses the same apt source as the static build path. test-builder
gains a --mirror option.

* feat: add Lao translations for nginx module pages
2026-07-23 13:55:42 +08:00
ssongliu f6ed11ac55 fix(database): support multiple MySQL user hosts (#13342) 2026-07-23 10:01:39 +08:00
ssongliu d330f60b2d fix: avoid duplicate disk IO aggregation (#13333) 2026-07-22 22:23:39 +08:00
CityFun 10c8cafcee chore: update dependencies (#13341) 2026-07-22 18:41:21 +08:00
ssongliu d1534c88aa fix: align dashboard column heights (#13332) 2026-07-22 14:20:58 +08:00
CityFun eb6b4c7cb7 chore: update dependencies (#13327) 2026-07-21 19:08:20 +08:00
ssongliu c77ddcfebe feat(database): improve MySQL user authorization (#13326) 2026-07-21 19:08:09 +08:00
ssongliu af12526a6c fix(container): make updates rollback-safe (#13315) 2026-07-21 15:40:07 +08:00
CityFun b347abe100 feat: Cache List Page Filter Conditions (#13314) 2026-07-21 15:39:46 +08:00
蘭 092b57413b feat(i18n): improve Lao (lo) language support (#13313) 2026-07-20 18:38:42 +08:00
ssongliu e8f35e4e06 fix: optimize terminal output performance (#13276) (#13312) 2026-07-20 18:26:00 +08:00
BoneNI b019e7526d feat: Add official Lao (lo_LA) language support (#13154)
* Add Lao language localization for lo.ts

* Add support for Lao language module

* Add Lao language translations to fu.ts

* Add Lao language support in i18n.go

* Add Lao language localization for lo.yaml
2026-07-20 17:23:23 +08:00
CityFun 1b06467521 feat: change website ssl apply logic (#13296) 2026-07-20 14:06:59 +08:00
ssongliu 7f3fd0cb1d feat: add runtime diagnostics (#13295) 2026-07-20 13:55:55 +08:00
CityFun 3e9848554b feat: update some translate (#13288) 2026-07-20 10:27:40 +08:00
ssongliu b02cdbc5f9 feat: add host system logs (#13285) 2026-07-17 17:54:36 +08:00
ssongliu d7723fd42a refactor(agent): replace onedrive sdk with graph api (#13284) 2026-07-17 13:48:04 +08:00
CityFun 8b265bb81e feat: Support fetching model lists for model accounts (#13281) 2026-07-17 13:47:49 +08:00
ssongliu 89b6c94e42 refactor(frontend): consolidate table components (#13280) 2026-07-17 10:02:40 +08:00
ssongliu 219ac160b6 feat: support mysql database users (#13278) 2026-07-16 14:24:38 +08:00
ssongliu 51ca1eab18 fix: refine virtual machine network hints (#13277) 2026-07-16 11:21:52 +08:00
ssongliu 1ab3da1fab fix: apply timeout to snapshot uploads (#13275)
* fix: apply timeout to snapshot uploads

* fix: honor snapshot upload timeouts for sftp and upyun
2026-07-16 11:21:26 +08:00
ssongliu 1d1b12c2fa fix: clean failed backup artifacts (#13274) 2026-07-15 18:06:19 +08:00
ssongliu 07a658582d fix: restore fail2ban bans after firewall restart (#13272) 2026-07-15 16:31:09 +08:00
ssongliu 61da74ae50 feat: hint file creation in protected directories (#13270) 2026-07-15 16:30:52 +08:00
王贺 90334f3cb0 fix: load SSL certificates when binding MCP website (#13271) 2026-07-15 16:08:55 +08:00
ssongliu fb9278bfc6 fix: prevent empty image import (#13269)
Refs #13228
2026-07-15 15:55:21 +08:00
ssongliu 7750936af6 feat: add node health check settings (#13268) 2026-07-15 15:55:02 +08:00
蘭 7b695af996 fix: Fix the issue of file permissions changing after decompressing files (#13267) 2026-07-15 15:54:05 +08:00
CityFun 2874849557 feat: Add Custom CLI Arguments Field for MCP Server Configuration (#13265) 2026-07-15 15:53:44 +08:00
王贺 9fd1f3e661 ci: remove disabled workflows (#13261) 2026-07-14 10:02:17 +08:00
Kobi Hikri 85182d9164 ci: pin third-party actions to full commit SHAs (#13259)
Pin the third-party actions referenced by mutable @master/@main tags to their
current commit SHA (tag kept in a trailing comment). Several run in jobs holding
secrets:

- SonarSource/sonarcloud-github-action@master (sonarcloud-scan.yml) — SONAR_TOKEN
- Yikun/hub-mirror-action@master (sync2gitee.yml) — GITEE_PRIVATE_KEY, GITEE_TOKEN
- fit2cloud/LLM-CodeReview-Action@main (llm-code-review.yml) — tokens + LLM API key
- crate-ci/typos@master (tyops-check.yml)

A moved tag would run unreviewed code with those secrets. Behaviour unchanged;
per GitHub's guidance to pin actions to a full-length commit SHA.

Signed-off-by: Kobi Hikri <kobi.hikri@gmail.com>
2026-07-14 09:58:52 +08:00
王贺 d9b2ea051f fix: resolve locale build errors (#13258) 2026-07-13 22:43:02 +08:00
ssongliu 097fe6dcfb fix: align remote download translations (#13255) 2026-07-13 18:16:58 +08:00
CityFun d5beca6d96 feat: change some translate (#13253) 2026-07-13 11:29:33 +08:00
ssongliu e3a356f893 feat: add VM orphan cleanup UI translations (#13252) 2026-07-13 10:48:51 +08:00
ssongliu 8be2a9ab02 fix: initialize enterprise database before hooks (#13247) 2026-07-10 18:31:04 +08:00
ssongliu 3d7f0f5143 fix: support cancelling chunk uploads (#13246) 2026-07-10 18:00:45 +08:00
ssongliu 1fd62fbaf7 fix: support cancelling chunk uploads (#13233)
* fix: support cancelling chunk uploads

* fix: validate chunk upload filenames
2026-07-10 15:46:59 +08:00
CityFun f1372dda85 core: Upgrade dependencies (#13231) 2026-07-09 21:38:14 +08:00
ssongliu 64c354adb2 feat: add VM overview status (#13232) 2026-07-09 21:36:48 +08:00
蘭 a379e1f840 ref: add some translate (#13225) 2026-07-09 18:32:16 +08:00
蘭 270ba799f7 feat: implement directory size caching and sorting functionality (#13220) 2026-07-09 11:11:37 +08:00
ssongliu 60dcfacb88 feat: add vm overview translations (#13224) 2026-07-09 11:11:15 +08:00
CityFun b474d720e9 style: change website page style (#13219) 2026-07-08 18:37:37 +08:00
ssongliu b2192b7252 fix: trim mariadb remote backup image tag (#13218) 2026-07-08 17:04:18 +08:00
ssongliu 6a988ad96a feat: update vm resource messages (#13216) 2026-07-08 15:01:49 +08:00
CityFun 029ac5efee feat: add some translate (#13215) 2026-07-07 18:42:55 +08:00
ssongliu 582cf1f533 feat: update vm and file resources (#13210) 2026-07-07 14:33:34 +08:00
ssongliu b8a4e8e9e0 chore: update xpack menu migration (#13207) 2026-07-06 11:14:29 +08:00
ssongliu 4a462aecc7 fix: reset firewall import dialogs on open (#13198) 2026-07-03 16:51:08 +08:00
ssongliu 74f221b895 fix: use svg renderer for container monitor charts (#13197) 2026-07-03 16:50:47 +08:00
ssongliu cbcb628bff fix: normalize firewall import addresses (#13196) 2026-07-03 16:37:03 +08:00
蘭 f602645e31 feat: Implement code editor layout with splitter for improved UI (#13195) 2026-07-03 16:01:44 +08:00
CityFun ec9e609f10 feat: add some translate (#13194) 2026-07-03 15:59:35 +08:00
ssongliu 1c1f3c7761 feat: support importing all firewall rules (#13192)
* feat: support importing all firewall rules

* fix: preserve selected firewall import action
2026-07-03 15:59:22 +08:00
ssongliu fae49284b7 fix: support mariadb manual backup args (#13193) 2026-07-03 15:58:15 +08:00
蘭 072a608a63 feat: File editor supports right-click operation (#13191) 2026-07-03 15:04:04 +08:00
蘭 1e6fd9c3a0 ref: Optimize menu settings (#13190) 2026-07-03 12:10:47 +08:00
ssongliu 23200cbe85 fix: support mariadb cronjob backup args (#13188) 2026-07-03 11:41:52 +08:00
ssongliu 08604e8c02 fix: fix dashboard running time calculation (#13187) 2026-07-03 11:41:37 +08:00
蘭 62a472d2d1 ref: refactor code editor theme management (#13186) 2026-07-03 11:41:22 +08:00
ssongliu 2c84665e8b chore: organize i18n language files (#13183) 2026-07-02 21:13:22 +08:00
蘭 616bd6830c feat: add menu accordion setting and update related components (#13182) 2026-07-02 21:13:07 +08:00
ssongliu e55ea96f00 feat: support virtual machine (#13171)
* feat: support virtual machine

* feat: add vm operation logs
2026-07-02 15:07:48 +08:00
蘭 aa676e139c feat: implement code editor theme management and synchronization (#13169) 2026-07-02 14:49:52 +08:00
ssongliu 7708fea58d feat: update operation log content (#13166) 2026-07-01 18:14:33 +08:00
ssongliu 34bdff01ad fix: allow ssl push on selected node (#13165) 2026-07-01 17:04:00 +08:00
CityFun 5a49579ddf feat: Optimize error messages for HTTP certificate requests (#13164) 2026-07-01 16:19:57 +08:00
CityFun b98a2b06c9 feat: Add multiple dimensions to website search (#13163) 2026-07-01 16:19:43 +08:00
ssongliu 9073b16baa feat: support compose rebuild operation (#13162) 2026-07-01 16:19:25 +08:00
ssongliu 2ec4db8d7e feat: adjust batch application installation API (#13161) 2026-07-01 16:19:12 +08:00
CityFun fe9628f91c feat: Add TaskLog for MCP Servers (#13160) 2026-07-01 16:18:59 +08:00
蘭 18a469e4be fix: Enhance Python language support in Monaco editor (#13158) 2026-07-01 16:18:42 +08:00
蘭 8c691cc074 fix: Resolve the issue of inability to access mobile web login (#13157) 2026-07-01 16:18:26 +08:00
ssongliu 2cd31a47f8 fix: complete operation logs (#13155) 2026-06-30 22:22:20 +08:00
CityFun d97aa9c2a0 feat: Optimize SSH session performance on Ubuntu 25. (#13152)
* feat: Optimize SSH session performance on Ubuntu 25.

* feat: Optimize SSH session performance on Ubuntu 25.

* feat: Optimize SSH session performance on Ubuntu 25.

* feat: Optimize SSH session performance on Ubuntu 25.
2026-06-30 18:36:28 +08:00
ssongliu f31f0a89ff feat: support certificate synchronization (#13153) 2026-06-30 18:31:45 +08:00
CityFun 0a1621166e feat: Unify the default website and default site under the same button. (#13149) 2026-06-30 15:56:25 +08:00
蘭 8623aa5318 feat: Enhance API authentication with HMAC-SHA256 support and user role management (#13148) 2026-06-30 14:16:36 +08:00
CityFun ec50599526 feat: Node runtime environment supports configuring whether to install node_modules. (#13147) 2026-06-30 14:16:22 +08:00
CityFun 458a30c520 feat: Add a description for syncing WAF IP groups. (#13143) 2026-06-29 18:42:02 +08:00
Andrea Leone 9a660db4c3 feat: add Ionos DNS Provider (#13024)
* feat: add Ionos DNS Provider

* Fix typo string formatting for APIKey in Ionos config
2026-06-29 16:11:33 +08:00
Reza Alipour Kondori f4fc71d32b add some translate and update Persian (#13132)
* Add Persian (fa) translations

* Update index.ts

Add fa to LOCALE_LOADERS

* Update fu.ts

Add fa item

* Update index.vue

enable Persian (fa) in language selector

* Update login-form.vue

enable Persian (fa) in language selector

* Create fa.yaml

add fa backend translations

* Update i18n.go

Add fa to langFiles variable

* Add Persian (fa) Translation to agent

* Add Persian README file

Add Persian (fa) README.fa.md to project

* fix(i18n): add Persian (fa) to backend validation and login dropdown

* Update README.fa.md

* Upade link in readme
2026-06-29 15:25:56 +08:00
王贺 e45a8185e3 fix container upgrade network IP handling (#13126) 2026-06-26 18:38:11 +08:00
CityFun 7be9189b3c fix: Fixed issue with reload ssl failed (#13129) 2026-06-26 18:08:38 +08:00
蘭 bdf13c01f6 ref: Improve support for Persian (fa) language (#13128) 2026-06-26 18:08:22 +08:00
王贺 902bc90aef fix: Fix updating the application host binding IP (#13127) 2026-06-26 18:08:08 +08:00
Octopusandocto-patch 27ba50d8be feat: add MiniMax M3 model to provider catalog (#13119)
Add the latest MiniMax-M3 flagship model to the MiniMax provider model
list and set it as the default. M3 offers a 1M context window, 128K max
output, reasoning, and image input. Drop the older M2.5 entries while
keeping M2.7 as an alternative, and update the account verification probe
to use M3.

Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
2026-06-26 17:55:04 +08:00
Reza Alipour Kondori fb483ecb90 Add Persian (fa) translations (#13100)
* Add Persian (fa) translations

* Update index.ts

Add fa to LOCALE_LOADERS

* Update fu.ts

Add fa item

* Update index.vue

enable Persian (fa) in language selector

* Update login-form.vue

enable Persian (fa) in language selector

* Create fa.yaml

add fa backend translations

* Update i18n.go

Add fa to langFiles variable

* Add Persian (fa) Translation to agent
2026-06-26 17:17:32 +08:00
蘭 2de7079dd2 feat: add new localization strings for skill management features (#13124) 2026-06-26 16:17:57 +08:00
CityFun 2e4ac604a8 fix: Fix incomplete log download issue (#13123) 2026-06-26 16:17:40 +08:00
CityFun 46af80142e feat: add username/password support for hermes-agent (#13121)
* feat: add username/password support for hermes-agent

* feat: add username/password support for hermes-agent

* feat: add username/password support for hermes-agent
2026-06-26 16:17:23 +08:00
CityFun 677b47dcdb fix: Fixed issue with openclaw config telegram failed (#13114) 2026-06-25 18:40:34 +08:00
王贺 56cccdf7c0 refactor: remove skills hub server leftovers (#13115) 2026-06-25 15:09:21 +08:00
蘭 3e0d0bb809 feat: add quick toggle comment functionality in the editor (#13107) 2026-06-24 18:23:51 +08:00
ssongliu 86aa6c09c6 fix: resolve snapshot extraction directory missing issue (#13104) 2026-06-24 18:23:37 +08:00
蘭 f67e1d897c ref: add some translate (#13102) 2026-06-24 18:23:23 +08:00
蘭 9e518daa62 ref: add some translate (#13093) 2026-06-23 14:05:48 +08:00
CityFun 2e5455e655 feat: Add “Remove old images during app upgrade” configuration to the App Store. (#13092) 2026-06-23 14:05:33 +08:00
CityFun 868f1db4c5 feat: Create and delete runtime environments, and add task logs. (#13081) 2026-06-18 18:46:32 +08:00
王贺 efd84b1e1b feat: support skills hub server frontend (#13074) 2026-06-18 16:57:53 +08:00
CityFun 5591fe50f0 chore: optimize runtime environment container configuration (#13071)
* chore: optimize runtime environment container configuration

* chore: optimize runtime environment container configuration
2026-06-17 19:09:31 +08:00
王贺 5e0a598799 feat: update waf prompt translations (#13070) 2026-06-17 17:00:28 +08:00
CityFun 45d7b30abc feat: Add support for syncing self-signed certificates and manually u… (#13068)
* feat: Add support for syncing self-signed certificates and manually uploaded certificates to other nodes.

* feat: Add support for syncing self-signed certificates and manually
2026-06-17 17:00:15 +08:00
ssongliu 46af88e1d0 feat: support app and image synchronization (#13067) 2026-06-17 15:41:09 +08:00
CityFun 7f331f5503 fix: 解决证书推送到其他节点报错的问题 (#13065) 2026-06-17 15:31:51 +08:00
igophper 7b74e09d30 fix: manage expanded node states in the tree view component (#13064) 2026-06-17 12:36:23 +08:00
蘭 01e4455110 refactor: Remove error logging for missing alert config in alert handling (#13060) 2026-06-17 11:08:45 +08:00
CityFun 922a2d21c8 feat: add protocolVersion param for MCP Server (#13057)
* feat: add protocolVersion param for MCP Server

* feat: add protocolVersion param for MCP Server
2026-06-16 16:47:27 +08:00
CityFun 439c4794b7 feat: Optimize the vLLM display (#13056) 2026-06-15 16:42:00 +08:00
ssongliu 5ac7f16b67 feat: optimize image deletion logs (#13053) 2026-06-15 15:41:46 +08:00
蘭 bfb79066b4 fix: Adjust the default values of alert attributes (#13052) 2026-06-15 15:41:33 +08:00
蘭 19602b42fb feat: add QR code verification endpoint to password expiration checks (#13049) 2026-06-15 15:15:09 +08:00
ssongliu f987af264f feat: disable script library auto-sync on startup (#13047) 2026-06-15 13:46:24 +08:00
ssongliu a6c76dffc3 feat: add snapshot rollback prompt (#13045) 2026-06-15 13:46:13 +08:00
CityFun a2eed66bc4 fix: fixed issue with refresh agent page failed (#13035) 2026-06-12 18:14:35 +08:00
CityFun a49b9b132d feat: update app upgrade logic (#13032) 2026-06-12 17:44:34 +08:00
ssongliu e8505a249a fix: resolve password expiration issue in multi-user mode (#13030) 2026-06-12 14:16:16 +08:00
811 changed files with 133288 additions and 82554 deletions
-25
View File
@@ -1,25 +0,0 @@
name: LLM Code Review
permissions:
contents: read
pull-requests: write
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
llm-code-review:
runs-on: ubuntu-latest
steps:
- uses: fit2cloud/LLM-CodeReview-Action@main
env:
GITHUB_TOKEN: ${{ secrets.FIT2CLOUDRD_LLM_CODE_REVIEW_TOKEN }}
OPENAI_API_KEY: ${{ secrets.ALIYUN_LLM_API_KEY }}
LANGUAGE: English
OPENAI_API_ENDPOINT: https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL: qwen2.5-coder-3b-instruct
PROMPT: "Please check the following code differences for any irregularities, potential issues, or optimization suggestions, and provide your answers in English."
top_p: 1
temperature: 1
# max_tokens: 10000
MAX_PATCH_LENGTH: 10000
IGNORE_PATTERNS: "/node_modules,*.md,/dist,/.github"
FILE_PATTERNS: "*.java,*.go,*.py,*.vue,*.ts,*.js,*.css,*.scss,*.html"
-21
View File
@@ -1,21 +0,0 @@
name: SonarCloud Scan
on:
push:
branches:
- dev
pull_request:
types: [opened, synchronize, reopened]
jobs:
sonarcloud:
name: SonarCloud
if: github.repository == '1Panel-dev/1Panel'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUBTOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+1 -1
View File
@@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Mirror the Github organization repos to Gitee. - name: Mirror the Github organization repos to Gitee.
uses: Yikun/hub-mirror-action@master uses: Yikun/hub-mirror-action@ba51c01b28a6c9f95a25d4f1bcf6af2a147c0e18 # master
with: with:
src: 'github/1Panel-dev' src: 'github/1Panel-dev'
dst: 'gitee/fit2cloud-feizhiyun' dst: 'gitee/fit2cloud-feizhiyun'
-11
View File
@@ -1,11 +0,0 @@
name: Typos Check
on: pull_request
jobs:
run:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@v2
- name: Check spelling
uses: crate-ci/typos@master
+26 -38
View File
@@ -1,9 +1,6 @@
<p align="center"><a href="https://1panel.pro"><img src="https://resource.1panel.pro/img/1panel-logo.png" alt="1Panel" width="300" /></a></p> <p align="center"><a href="https://1panel.pro"><img src="https://resource.1panel.pro/img/1panel-logo.png" alt="1Panel" width="300" /></a></p>
<h3 align="center">The open-source VPS control panel with native AI agent support</h3>
<p align="center"> <p align="center">
Trusted by <strong>2,000,000+</strong> self-hosters worldwide Loved by a global community of <strong>2.5M+</strong> self-hosters.
</p> </p>
<p align="center"> <p align="center">
@@ -12,7 +9,6 @@
<p align="center"> <p align="center">
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://shields.io/github/license/1Panel-dev/1Panel?color=%231890FF" alt="License: GPL v3"></a> <a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://shields.io/github/license/1Panel-dev/1Panel?color=%231890FF" alt="License: GPL v3"></a>
<a href="https://app.codacy.com/gh/1Panel-dev/1Panel"><img src="https://app.codacy.com/project/badge/Grade/da67574fd82b473992781d1386b937ef" alt="Codacy"></a>
<a href="https://discord.gg/bUpUqWqdRr"><img src="https://img.shields.io/discord/1318846410149335080?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="Discord"></a> <a href="https://discord.gg/bUpUqWqdRr"><img src="https://img.shields.io/discord/1318846410149335080?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="Discord"></a>
<a href="https://github.com/1Panel-dev/1Panel/releases"><img src="https://img.shields.io/github/v/release/1Panel-dev/1Panel" alt="GitHub release"></a> <a href="https://github.com/1Panel-dev/1Panel/releases"><img src="https://img.shields.io/github/v/release/1Panel-dev/1Panel" alt="GitHub release"></a>
<a href="https://github.com/1Panel-dev/1Panel"><img src="https://img.shields.io/github/stars/1Panel-dev/1Panel?color=%231890FF&style=flat-square" alt="Stars"></a> <a href="https://github.com/1Panel-dev/1Panel"><img src="https://img.shields.io/github/stars/1Panel-dev/1Panel?color=%231890FF&style=flat-square" alt="Stars"></a>
@@ -25,7 +21,7 @@
<a href="/docs/README.pt-br.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-d9d9d9"></a> <a href="/docs/README.pt-br.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-d9d9d9"></a>
<a href="/docs/README.ar.md"><img alt="العربية" src="https://img.shields.io/badge/العربية-d9d9d9"></a> <a href="/docs/README.ar.md"><img alt="العربية" src="https://img.shields.io/badge/العربية-d9d9d9"></a>
<a href="/docs/README.de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-d9d9d9"></a> <a href="/docs/README.de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-d9d9d9"></a>
<a href="/docs/README.es.md"><img alt="Español" src="https://img.shields.io/badge/Español-d9d9d9"></a> <a href="/docs/README.es-es.md"><img alt="Español" src="https://img.shields.io/badge/Español-d9d9d9"></a>
<a href="/docs/README.fr.md"><img alt="français" src="https://img.shields.io/badge/français-d9d9d9"></a> <a href="/docs/README.fr.md"><img alt="français" src="https://img.shields.io/badge/français-d9d9d9"></a>
<a href="/docs/README.ko.md"><img alt="한국어" src="https://img.shields.io/badge/한국어-d9d9d9"></a> <a href="/docs/README.ko.md"><img alt="한국어" src="https://img.shields.io/badge/한국어-d9d9d9"></a>
<a href="/docs/README.id.md"><img alt="Bahasa Indonesia" src="https://img.shields.io/badge/Bahasa Indonesia-d9d9d9"></a> <a href="/docs/README.id.md"><img alt="Bahasa Indonesia" src="https://img.shields.io/badge/Bahasa Indonesia-d9d9d9"></a>
@@ -33,40 +29,36 @@
<a href="/docs/README.tr.md"><img alt="Türkçe" src="https://img.shields.io/badge/Türkçe-d9d9d9"></a> <a href="/docs/README.tr.md"><img alt="Türkçe" src="https://img.shields.io/badge/Türkçe-d9d9d9"></a>
<a href="/docs/README.ru.md"><img alt="Русский" src="https://img.shields.io/badge/Русский-d9d9d9"></a> <a href="/docs/README.ru.md"><img alt="Русский" src="https://img.shields.io/badge/Русский-d9d9d9"></a>
<a href="/docs/README.ms.md"><img alt="Bahasa Melayu" src="https://img.shields.io/badge/Bahasa Melayu-d9d9d9"></a> <a href="/docs/README.ms.md"><img alt="Bahasa Melayu" src="https://img.shields.io/badge/Bahasa Melayu-d9d9d9"></a>
<a href="/docs/README.fa.md"><img alt="Persian" src="https://img.shields.io/badge/%D9%81%D8%A7%D8%B1%D8%B3%DB%8C-d9d9d9"></a>
<a href="/docs/README.lo.md"><img alt="ພາສາລາວ" src="https://img.shields.io/badge/%E0%BA%9E%E0%BA%B2%E0%BA%AA%E0%BA%B2%E0%BA%A5%E0%BA%B2%E0%BA%A7-d9d9d9"></a>
</p> </p>
--- ---
## What is 1Panel? ## What is 1Panel?
1Panel is a modern, open-source VPS control panel — and the only one with **native AI agent support**. Run Ollama models, deploy OpenClaw agents, and manage your entire server stack from one clean web interface. No CLI memorization required. 1Panel is a modern, open-source Linux server management panel and a lightweight AI management platform. Through an intuitive web interface, it provides users with comprehensive, one-stop server management capabilities:
- **AI Management**: Offers a unified management platform from bare metal to agents (Metal-to-Agent). It integrates an AI gateway, and Skills Hub, while supporting centralized management of agents and models.
👉 Watch the [2-minute introduction](https://www.youtube.com/watch?v=Jl_wqp-XA08) - **Efficient Visual Operations**: Easily manage Linux servers through a web-based GUI, streamlining tasks such as host monitoring, file management, database management, and container management.
- **Rapid Website Deployment**: Deeply integrates with popular website builders like WordPress and Halo. It enables one-click domain binding and SSL certificate configuration, significantly lowering the barrier to website creation.
- **Curated App Store**: Features a built-in store of high-quality open-source applications, providing one-click installation and upgrade services to effortlessly extend server capabilities.
- **Enterprise-Grade Security**: Deploys applications based on container technology to effectively minimize vulnerability exposure. It also provides security features such as WAF and log auditing to ensure comprehensive server protection.
- **One-Click Data Backup**: Supports one-click backup and restoration, and integrates with various cloud storage solutions to ensure data security and prevent loss.
## Why 1Panel? ## Why 1Panel?
| | 1Panel | cPanel / Plesk | aaPanel | Webmin | | | 1Panel | cPanel / Plesk | aaPanel | Webmin |
|--|--------|----------------|---------|--------| |--|--------|----------------|---------|--------|
| Free & open source | ✅ | ❌ | Partial | ✅ | | Free & open source | ✅ | ❌ | Partial | ✅ |
| Native AI agent runtime | ✅ | ❌ | ❌ | ❌ | | AI management | ✅ | ❌ | ❌ | ❌ |
| One-click app marketplace | ✅ 165+ apps | ❌ | ✅ | ❌ | | One-click app marketplace | ✅ 165+ apps | ❌ | ✅ | ❌ |
| Modern UI (post-2020) | ✅ | ❌ | Partial | ❌ | | Modern UI (post-2020) | ✅ | ❌ | Partial | ❌ |
| Docker / container management | ✅ | ❌ | ❌ | ❌ | | Docker / container management | ✅ | ❌ | ❌ | ❌ |
| Active development | ✅ | ✅ | ✅ | Slow | | Active development | ✅ | ✅ | ✅ | Slow |
## Key Features
- **AI Agent Runtime**: Deploy Ollama LLMs, spin up OpenClaw personal agents, and monitor GPU utilization — all from the dashboard. No separate AI stack to manage.
- **One-Click Website Deployment**: Launch production-ready websites with automatic domain binding, SSL provisioning, and Nginx config — zero manual setup.
- **App Marketplace**: 165+ trusted open-source apps (Nextcloud, Bitwarden, Umami, NocoBase, and more) installed and updated with a single click.
- **Docker & Container Management**: Create, start, stop, and inspect containers, images, networks, and volumes through a visual UI — no CLI juggling.
- **Security Out of the Box**: Firewall rules, fail2ban, container isolation, WAF, and audit logs — configured and running from day one.
- **Backup & Restore**: Schedule automated backups to AWS S3, Cloudflare R2, or local storage. Restore any snapshot in one click.
## Quick Start ## Quick Start
> **Requirements:** Linux VPS (Debian / Ubuntu / CentOS / Rocky), 1 GB RAM, internet access. Prepare your Linux server and run the following script:
> Takes ~60 seconds.
```bash ```bash
bash -c "$(curl -sSL https://resource.1panel.pro/v2/quick_start.sh)" bash -c "$(curl -sSL https://resource.1panel.pro/v2/quick_start.sh)"
@@ -81,24 +73,20 @@ Run `1pctl user-info` via SSH if you need to retrieve your access credentials.
## Pro Edition ## Pro Edition
1Panel OSS is free forever. Pro adds features built for teams and production workloads: 1Panel OSS is free forever. 1Panel Pro and Ent adds features built for teams and production workloads:
| Feature | OSS | Pro | | Feature | OSS | Pro | Ent |
|---------|:---:|:---:| |---------|:---:|:---:|:---:|
| One-click app installs | ✅ | ✅ | | One-click app installs | ✅ | ✅ | ✅ |
| AI agents (OpenClaw) | 1 agent | Unlimited | | AI agents (OpenClaw) | 5 agent | Unlimited | ✅ |
| WAF & advanced security | Basic | ✅ | | WAF & advanced security | Basic | ✅ | ✅ |
| Website tamper protection | ❌ | ✅ | | Website tamper protection | ❌ | ✅ | ✅ |
| Website uptime monitoring | ❌ | ✅ | | Website uptime monitoring | ❌ | ✅ | ✅ |
| Multi-node management | ❌ | ✅ | | Multi-node management | ❌ | ✅ | ✅ |
| Custom logo & theme | ❌ | ✅ | | Custom logo & theme | ❌ | ✅ | ✅ |
| Priority support | ❌ | ✅ | | KVM Web UI | ❌ | ❌ | ✅ |
| AI Gateway | ❌ | ❌ | ✅ |
**From $80/year.** [Compare plans & start 30-day free trial →](https://1panel.pro/pricing) | Priority support | ❌ | ❌ | ✅ |
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=1Panel-dev/1Panel&type=Date)](https://star-history.com/#1Panel-dev/1Panel&Date)
## Community & Support ## Community & Support
+103
View File
@@ -500,6 +500,27 @@ func (b *BaseApi) GetAgentAccountModels(c *gin.Context) {
helper.SuccessWithData(c, list) helper.SuccessWithData(c, list)
} }
// @Tags AI
// @Summary Discover custom provider models
// @Accept json
// @Param request body dto.AgentAccountModelDiscoverReq true "request"
// @Success 200 {array} dto.AgentAccountModel
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/accounts/models/discover [post]
func (b *BaseApi) DiscoverAgentAccountModels(c *gin.Context) {
var req dto.AgentAccountModelDiscoverReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
list, err := agentService.DiscoverAccountModels(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags AI // @Tags AI
// @Summary Create model account model // @Summary Create model account model
// @Accept json // @Accept json
@@ -1357,6 +1378,88 @@ func (b *BaseApi) UninstallAgentSkill(c *gin.Context) {
helper.Success(c) helper.Success(c)
} }
// @Tags AI
// @Summary List OpenClaw plugins
// @Accept json
// @Param request body dto.AgentPluginsReq true "request"
// @Success 200 {array} dto.AgentPluginItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/list [post]
func (b *BaseApi) ListAgentPlugins(c *gin.Context) {
var req dto.AgentPluginsReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.ListPlugins(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Search OpenClaw plugins
// @Accept json
// @Param request body dto.AgentPluginSearchReq true "request"
// @Success 200 {array} dto.AgentPluginSearchItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/search [post]
func (b *BaseApi) SearchAgentPlugins(c *gin.Context) {
var req dto.AgentPluginSearchReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.SearchPlugins(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Install an OpenClaw marketplace plugin
// @Accept json
// @Param request body dto.AgentPluginMarketInstallReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/install [post]
func (b *BaseApi) InstallAgentMarketPlugin(c *gin.Context) {
var req dto.AgentPluginMarketInstallReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.InstallMarketPlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Operate an OpenClaw plugin
// @Accept json
// @Param request body dto.AgentPluginOperateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/operate [post]
func (b *BaseApi) OperateAgentPlugin(c *gin.Context) {
var req dto.AgentPluginOperateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.OperatePlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI // @Tags AI
// @Summary Login Agent Weixin channel // @Summary Login Agent Weixin channel
// @Accept json // @Accept json
+40
View File
@@ -2,11 +2,14 @@ package v2
import ( import (
"errors" "errors"
"net/http"
"net/url" "net/url"
"strings" "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/constant"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -294,6 +297,34 @@ func (b *BaseApi) UpdateAlertConfig(c *gin.Context) {
return return
} }
if err := alertService.UpdateAlertConfig(req, loadAuditUser(c)); err != nil { if err := alertService.UpdateAlertConfig(req, loadAuditUser(c)); err != nil {
switch {
case errors.Is(err, repo.ErrAlertConfigRevisionConflict):
helper.ErrorWithBusinessCode(c, http.StatusConflict, "ALERT_CONFIG_REVISION_CONFLICT", "ErrInvalidParams", err)
case errors.Is(err, repo.ErrAlertConfigRevisionRequired):
helper.ErrorWithBusinessCode(c, http.StatusConflict, "ALERT_CONFIG_REVISION_REQUIRED", "ErrInvalidParams", err)
default:
helper.InternalServer(c, err)
}
return
}
helper.Success(c)
}
// @Tags Alert
// @Summary Update alert config status
// @Accept json
// @Param request body dto.AlertConfigStatusUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /alert/config/status [post]
// @x-panel-log {"bodyKeys":["id","status"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置状态 [id][status]","formatEN":"update alert config status [id][status]"}
func (b *BaseApi) UpdateAlertConfigStatus(c *gin.Context) {
var req dto.AlertConfigStatusUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := alertService.UpdateAlertConfigStatus(req, loadAuditUser(c)); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
@@ -346,6 +377,15 @@ func (b *BaseApi) TestAlertConfig(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if req.Type == constant.Custom {
result, err := alertService.TestCustomAlertConfig(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
return
}
flag, err := alertService.TestAlertConfig(req) flag, err := alertService.TestAlertConfig(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+6 -1
View File
@@ -413,10 +413,15 @@ func (b *BaseApi) Backup(c *gin.Context) {
switch req.Type { switch req.Type {
case "app": case "app":
if _, err := backupService.AppBackup(req); err != nil { record, err := backupService.AppBackup(req)
if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
if req.IsImmediate {
helper.SuccessWithData(c, record)
return
}
case "mysql", "mariadb", constant.AppMysqlCluster: case "mysql", "mariadb", constant.AppMysqlCluster:
if err := backupService.MysqlBackup(req); err != nil { if err := backupService.MysqlBackup(req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+21 -1
View File
@@ -439,7 +439,7 @@ func (b *BaseApi) ContainerItemStats(c *gin.Context) {
return return
} }
data, err := containerService.ContainerItemStats(req) data, err := containerService.ContainerItemStats(c.Request.Context(), req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -881,6 +881,26 @@ func (b *BaseApi) ComposeUpdate(c *gin.Context) {
helper.Success(c) helper.Success(c)
} }
// @Tags Container Compose
// @Summary Pin compose
// @Accept json
// @Param request body dto.ComposePin true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/compose/pin [post]
func (b *BaseApi) ComposePin(c *gin.Context) {
var req dto.ComposePin
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := containerService.ComposePin(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Container Compose // @Tags Container Compose
// @Summary Load compose environment variables // @Summary Load compose environment variables
// @Accept json // @Accept json
+215 -12
View File
@@ -41,20 +41,40 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
} }
// @Tags Database Mysql // @Tags Database Mysql
// @Summary Bind user of mysql database // @Summary List mysql users
// @Accept json // @Accept json
// @Param request body dto.BindUser true "request" // @Param request body dto.MysqlUserSearch true "request"
// @Success 200 // @Success 200 {array} dto.MysqlUser
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /databases/bind [post] // @Router /databases/users/search [post]
// @x-panel-log {"bodyKeys":["database", "username"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"绑定 mysql 数据库名 [database] [username]","formatEN":"bind mysql database [database] [username]"} func (b *BaseApi) ListMysqlUsers(c *gin.Context) {
func (b *BaseApi) BindUser(c *gin.Context) { var req dto.MysqlUserSearch
var req dto.BindUser
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
data, err := mysqlService.ListUsers(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Database Mysql
// @Summary Create mysql user
// @Accept json
// @Param request body dto.MysqlUserCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users [post]
// @x-panel-log {"bodyKeys":["database","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [database] 用户 [username]@[host]","formatEN":"create mysql database [database] user [username]@[host]"}
func (b *BaseApi) CreateMysqlUser(c *gin.Context) {
var req dto.MysqlUserCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 { if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password) password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil { if err != nil {
@@ -63,8 +83,191 @@ func (b *BaseApi) BindUser(c *gin.Context) {
} }
req.Password = string(password) req.Password = string(password)
} }
if err := mysqlService.CreateUser(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
if err := mysqlService.BindUser(req); err != nil { // @Tags Database Mysql
// @Summary Delete mysql user
// @Accept json
// @Param request body dto.MysqlUserDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users/del [post]
// @x-panel-log {"bodyKeys":["database","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除 mysql 数据库 [database] 用户 [username]@[host]","formatEN":"delete mysql database [database] user [username]@[host]"}
func (b *BaseApi) DeleteMysqlUser(c *gin.Context) {
var req dto.MysqlUserDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mysqlService.DeleteUser(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary Update mysql user
// @Accept json
// @Param request body dto.MysqlUserUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users/update [post]
// @x-panel-log {"bodyKeys":["database","username","host","newHost","description"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 mysql 数据库 [database] 用户 [username] 访问权限 [host] => [newHost] 描述 [description]","formatEN":"update mysql database [database] user [username] access [host] => [newHost] description [description]"}
func (b *BaseApi) UpdateMysqlUser(c *gin.Context) {
var req dto.MysqlUserUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mysqlService.UpdateUser(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary Change mysql user password
// @Accept json
// @Param request body dto.MysqlUserPassword true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users/password [post]
// @x-panel-log {"bodyKeys":["database","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 mysql 数据库 [database] 用户 [username]@[host] 密码","formatEN":"update mysql database [database] user [username]@[host] password"}
func (b *BaseApi) ChangeMysqlUserPassword(c *gin.Context) {
var req dto.MysqlUserPassword
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
if err := mysqlService.ChangeUserPassword(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary Save mysql user password locally
// @Accept json
// @Param request body dto.MysqlUserPassword true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users/password/save [post]
// @x-panel-log {"bodyKeys":["database","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"补充 mysql 数据库 [database] 用户 [username]@[host] 密码","formatEN":"save mysql database [database] user [username]@[host] password locally"}
func (b *BaseApi) SaveMysqlUserPassword(c *gin.Context) {
var req dto.MysqlUserPassword
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
if err := mysqlService.SaveUserPassword(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary List mysql grants
// @Accept json
// @Param request body dto.MysqlUserSearch true "request"
// @Success 200 {array} dto.MysqlGrant
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/grants/search [post]
func (b *BaseApi) ListMysqlGrants(c *gin.Context) {
var req dto.MysqlUserSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := mysqlService.ListGrants(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Database Mysql
// @Summary List mysql grant summary
// @Accept json
// @Param request body dto.MysqlGrantSummarySearch true "request"
// @Success 200 {object} map[string][]dto.MysqlUser
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/grants/summary [post]
func (b *BaseApi) ListMysqlGrantSummary(c *gin.Context) {
var req dto.MysqlGrantSummarySearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := mysqlService.ListGrantSummary(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Database Mysql
// @Summary Grant mysql user
// @Accept json
// @Param request body dto.MysqlGrantCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/grants [post]
// @x-panel-log {"bodyKeys":["database","db","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"授权 mysql 数据库 [database] 用户 [username]@[host] 访问 [db]","formatEN":"grant mysql database [database] user [username]@[host] access to [db]"}
func (b *BaseApi) GrantMysqlUser(c *gin.Context) {
var req dto.MysqlGrantCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mysqlService.GrantUser(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary Revoke mysql grant
// @Accept json
// @Param request body dto.MysqlGrantDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/grants/del [post]
// @x-panel-log {"bodyKeys":["database","db","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"取消 mysql 数据库 [database] 用户 [username]@[host] 对 [db] 的授权","formatEN":"revoke mysql database [database] user [username]@[host] access to [db]"}
func (b *BaseApi) RevokeMysqlGrant(c *gin.Context) {
var req dto.MysqlGrantDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mysqlService.RevokeGrant(req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
@@ -94,14 +297,14 @@ func (b *BaseApi) UpdateMysqlDescription(c *gin.Context) {
} }
// @Tags Database Mysql // @Tags Database Mysql
// @Summary Change mysql password // @Summary Change mysql root password
// @Accept json // @Accept json
// @Param request body dto.ChangeDBInfo true "request" // @Param request body dto.ChangeDBInfo true "request"
// @Success 200 // @Success 200
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /databases/change/password [post] // @Router /databases/change/password [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_mysqls","output_column":"name","output_value":"name"}],"formatZH":"更新数据库 [name] 密码","formatEN":"Update database [name] password"} // @x-panel-log {"bodyKeys":["database"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新数据库 [database] root 密码","formatEN":"Update database [database] root password"}
func (b *BaseApi) ChangeMysqlPassword(c *gin.Context) { func (b *BaseApi) ChangeMysqlPassword(c *gin.Context) {
var req dto.ChangeDBInfo var req dto.ChangeDBInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
@@ -125,14 +328,14 @@ func (b *BaseApi) ChangeMysqlPassword(c *gin.Context) {
} }
// @Tags Database Mysql // @Tags Database Mysql
// @Summary Change mysql access // @Summary Change mysql root access
// @Accept json // @Accept json
// @Param request body dto.ChangeDBInfo true "request" // @Param request body dto.ChangeDBInfo true "request"
// @Success 200 // @Success 200
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /databases/change/access [post] // @Router /databases/change/access [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_mysqls","output_column":"name","output_value":"name"}],"formatZH":"更新数据库 [name] 访问权限","formatEN":"Update database [name] access"} // @x-panel-log {"bodyKeys":["database"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新数据库 [database] root 访问权限","formatEN":"Update database [database] root access"}
func (b *BaseApi) ChangeMysqlAccess(c *gin.Context) { func (b *BaseApi) ChangeMysqlAccess(c *gin.Context) {
var req dto.ChangeDBInfo var req dto.ChangeDBInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
+12 -8
View File
@@ -37,14 +37,17 @@ var (
cronjobService = service.NewICronjobService() cronjobService = service.NewICronjobService()
fileService = service.NewIFileService() fileService = service.NewIFileService()
fileHistoryService = service.NewIFileHistoryService() fileHistoryService = service.NewIFileHistoryService()
fileShareService = service.NewIFileShareService() fileShareService = service.NewIFileShareService()
sshService = service.NewISSHService() sshService = service.NewISSHService()
firewallService = service.NewIFirewallService() firewallService = service.NewIFirewallService()
iptablesService = service.NewIIptablesService() firewallSettingService = service.NewIFirewallSettingService()
monitorService = service.NewIMonitorService() forwardingService = service.NewIForwardingService()
systemService = service.NewISystemService() dockerPortGuardService = service.NewIDockerPortGuardService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
runtimeDiagnosticsService = service.NewIRuntimeDiagnosticsService()
deviceService = service.NewIDeviceService() deviceService = service.NewIDeviceService()
fail2banService = service.NewIFail2BanService() fail2banService = service.NewIFail2BanService()
@@ -59,6 +62,7 @@ var (
websiteDnsAccountService = service.NewIWebsiteDnsAccountService() websiteDnsAccountService = service.NewIWebsiteDnsAccountService()
websiteSSLService = service.NewIWebsiteSSLService() websiteSSLService = service.NewIWebsiteSSLService()
websiteAcmeAccountService = service.NewIWebsiteAcmeAccountService() websiteAcmeAccountService = service.NewIWebsiteAcmeAccountService()
websiteTemplateService = service.NewIWebsiteTemplateService()
nginxService = service.NewINginxService() nginxService = service.NewINginxService()
+535 -21
View File
@@ -12,7 +12,9 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"syscall" "syscall"
"time"
"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"
@@ -28,6 +30,86 @@ import (
qrcode "github.com/skip2/go-qrcode" qrcode "github.com/skip2/go-qrcode"
) )
var cancelledChunkUploads = struct {
sync.RWMutex
ids map[string]struct{}
}{ids: make(map[string]struct{})}
type chunkUploadLock struct {
mutex sync.Mutex
refs int
}
var chunkUploadLocks = struct {
sync.Mutex
items map[string]*chunkUploadLock
}{items: make(map[string]*chunkUploadLock)}
type completedChunkUpload struct {
dstDir string
filename string
fileSize int64
}
var completedChunkUploads = struct {
sync.RWMutex
items map[string]completedChunkUpload
}{items: make(map[string]completedChunkUpload)}
var activeChunkUploadTTL = 24 * time.Hour
var (
errChunkUploadCancelled = errors.New("upload cancelled")
errInvalidChunkUpload = errors.New("invalid chunk upload")
)
type activeChunkUpload struct {
upload completedChunkUpload
expiresAt time.Time
timer *time.Timer
}
var activeChunkUploads = struct {
sync.RWMutex
items map[string]activeChunkUpload
}{items: make(map[string]activeChunkUpload)}
type resumableUploadChunk struct {
UploadID string
Filename string
DstDir string
ChunkIndex int
ChunkCount int
Offset int64
FileSize int64
Overwrite bool
}
func invalidChunkUploadError(message string) error {
return fmt.Errorf("%w: %s", errInvalidChunkUpload, message)
}
func isRetryableChunkUploadError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errChunkUploadCancelled) ||
errors.Is(err, errInvalidChunkUpload) ||
errors.Is(err, os.ErrExist) ||
errors.Is(err, os.ErrPermission) ||
errors.Is(err, os.ErrInvalid) ||
errors.Is(err, syscall.ENOSPC) ||
errors.Is(err, syscall.EDQUOT) ||
errors.Is(err, syscall.EROFS) ||
errors.Is(err, syscall.EFBIG) ||
errors.Is(err, syscall.ENAMETOOLONG) ||
errors.Is(err, syscall.ENOTDIR) ||
errors.Is(err, syscall.EISDIR) {
return false
}
return true
}
// @Tags File // @Tags File
// @Summary List files // @Summary List files
// @Accept json // @Accept json
@@ -467,11 +549,7 @@ func (b *BaseApi) UploadFiles(c *gin.Context) {
continue continue
} }
dstInfo, statErr := os.Stat(dstFilename) dstInfo, statErr := os.Stat(dstFilename)
if overwrite { err = finalizeUploadedFile(tmpFilename, dstFilename, overwrite)
_ = os.Remove(dstFilename)
}
err = os.Rename(tmpFilename, dstFilename)
if err != nil { if err != nil {
_ = os.Remove(tmpFilename) _ = os.Remove(tmpFilename)
e := fmt.Errorf("upload [%s] file failed, err: %v", file.Filename, err) e := fmt.Errorf("upload [%s] file failed, err: %v", file.Filename, err)
@@ -606,10 +684,35 @@ func (b *BaseApi) StopWget(c *gin.Context) {
return return
} }
files.CancelDownload(req.Key) if err := files.CancelDownload(req.Key); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c) helper.Success(c)
} }
// @Tags File
// @Summary Remove finished download progress records without deleting files
// @Accept json
// @Param request body request.FileProcessRemoveReq true "request"
// @Success 200 {object} response.FileProcessKeys
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/wget/process/remove [post]
// @x-panel-log {"bodyKeys":["keys"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"移除已结束下载记录 [keys]","formatEN":"Remove finished download records [keys]"}
func (b *BaseApi) RemoveWgetRecords(c *gin.Context) {
var req request.FileProcessRemoveReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
keys, err := files.RemoveDownloadRecords(req.Keys)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, response.FileProcessKeys{Keys: keys})
}
// @Tags File // @Tags File
// @Summary Move file // @Summary Move file
// @Accept json // @Accept json
@@ -631,6 +734,26 @@ func (b *BaseApi) MoveFile(c *gin.Context) {
helper.Success(c) helper.Success(c)
} }
// @Tags File
// @Summary Stop file move task
// @Accept json
// @Param request body request.FileMoveStopReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/move/stop [post]
func (b *BaseApi) StopMoveFile(c *gin.Context) {
var req request.FileMoveStopReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := fileService.StopMvFile(req.TaskID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File // @Tags File
// @Summary Download file // @Summary Download file
// @Accept json // @Accept json
@@ -785,6 +908,289 @@ func (b *BaseApi) DepthDirSize(c *gin.Context) {
helper.SuccessWithData(c, res) helper.SuccessWithData(c, res)
} }
func lockChunkUpload(uploadID string) func() {
chunkUploadLocks.Lock()
lock, ok := chunkUploadLocks.items[uploadID]
if !ok {
lock = &chunkUploadLock{}
chunkUploadLocks.items[uploadID] = lock
}
lock.refs++
chunkUploadLocks.Unlock()
lock.mutex.Lock()
return func() {
lock.mutex.Unlock()
chunkUploadLocks.Lock()
lock.refs--
if lock.refs == 0 {
delete(chunkUploadLocks.items, uploadID)
}
chunkUploadLocks.Unlock()
}
}
func resumableUploadPartPath(dstDir, uploadID string) string {
return filepath.Join(dstDir, fmt.Sprintf(".1panel-upload-%s.part", uploadID))
}
func finalizeUploadedFile(tmpFile, dstFile string, overwrite bool) error {
if overwrite {
return os.Rename(tmpFile, dstFile)
}
if err := os.Link(tmpFile, dstFile); err != nil {
return err
}
if err := os.Remove(tmpFile); err != nil {
if rollbackErr := os.Remove(dstFile); rollbackErr != nil {
return fmt.Errorf("remove upload temporary file failed: %v, rollback destination failed: %w", err, rollbackErr)
}
return err
}
return nil
}
func registerActiveChunkUpload(uploadID string, upload completedChunkUpload) error {
activeChunkUploads.Lock()
defer activeChunkUploads.Unlock()
if active, ok := activeChunkUploads.items[uploadID]; ok {
if active.upload != upload {
return invalidChunkUploadError("upload ID is already used by another file")
}
active.timer.Stop()
}
expiresAt := time.Now().Add(activeChunkUploadTTL)
timer := time.AfterFunc(activeChunkUploadTTL, func() {
expireActiveChunkUpload(uploadID, expiresAt)
})
activeChunkUploads.items[uploadID] = activeChunkUpload{
upload: upload,
expiresAt: expiresAt,
timer: timer,
}
return nil
}
func loadActiveChunkUpload(uploadID string) (completedChunkUpload, bool) {
activeChunkUploads.RLock()
active, ok := activeChunkUploads.items[uploadID]
activeChunkUploads.RUnlock()
return active.upload, ok
}
func deleteActiveChunkUpload(uploadID string) {
activeChunkUploads.Lock()
if active, ok := activeChunkUploads.items[uploadID]; ok {
active.timer.Stop()
}
delete(activeChunkUploads.items, uploadID)
activeChunkUploads.Unlock()
}
func discardActiveChunkUpload(uploadID, partFile string) error {
deleteActiveChunkUpload(uploadID)
if err := os.Remove(partFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove upload temporary file failed: %w", err)
}
return nil
}
func finalizeActiveChunkUpload(uploadID, partFile, dstFile string, overwrite bool) error {
if err := finalizeUploadedFile(partFile, dstFile, overwrite); err != nil {
if removeErr := discardActiveChunkUpload(uploadID, partFile); removeErr != nil {
return errors.Join(err, removeErr)
}
return err
}
return nil
}
func expireActiveChunkUpload(uploadID string, expiresAt time.Time) {
unlock := lockChunkUpload(uploadID)
defer unlock()
activeChunkUploads.Lock()
active, ok := activeChunkUploads.items[uploadID]
if !ok || !active.expiresAt.Equal(expiresAt) {
activeChunkUploads.Unlock()
return
}
delete(activeChunkUploads.items, uploadID)
activeChunkUploads.Unlock()
partFile := resumableUploadPartPath(active.upload.dstDir, uploadID)
if err := os.Remove(partFile); err != nil && !os.IsNotExist(err) {
global.LOG.Warnf("remove inactive upload part [%s] failed: %v", partFile, err)
}
}
func removeActiveResumableUploadPart(uploadID string) error {
unlock := lockChunkUpload(uploadID)
defer unlock()
upload, ok := loadActiveChunkUpload(uploadID)
if !ok {
return nil
}
err := os.Remove(resumableUploadPartPath(upload.dstDir, uploadID))
if err == nil || os.IsNotExist(err) {
deleteActiveChunkUpload(uploadID)
return nil
}
return err
}
func loadCompletedChunkUpload(uploadID string) (completedChunkUpload, bool) {
completedChunkUploads.RLock()
completed, ok := completedChunkUploads.items[uploadID]
completedChunkUploads.RUnlock()
return completed, ok
}
func markChunkUploadCompleted(uploadID string, completed completedChunkUpload) {
completedChunkUploads.Lock()
completedChunkUploads.items[uploadID] = completed
completedChunkUploads.Unlock()
time.AfterFunc(10*time.Minute, func() {
completedChunkUploads.Lock()
delete(completedChunkUploads.items, uploadID)
completedChunkUploads.Unlock()
})
}
func writeResumableUploadChunk(chunk resumableUploadChunk, chunkData []byte) error {
unlock := lockChunkUpload(chunk.UploadID)
defer unlock()
if chunkUploadCancelled(chunk.UploadID) {
return errChunkUploadCancelled
}
if chunk.UploadID == "" || filepath.Base(chunk.UploadID) != chunk.UploadID || strings.ContainsAny(chunk.UploadID, `/\`) {
return invalidChunkUploadError("invalid upload ID")
}
if chunk.Filename == "" || filepath.Base(chunk.Filename) != chunk.Filename || strings.ContainsAny(chunk.Filename, `/\`) {
return invalidChunkUploadError("invalid filename")
}
if strings.TrimSpace(chunk.DstDir) == "" {
return invalidChunkUploadError("upload destination is required")
}
dstDir := filepath.Clean(strings.TrimSpace(chunk.DstDir))
if chunk.ChunkCount <= 0 || chunk.ChunkIndex < 0 || chunk.ChunkIndex >= chunk.ChunkCount {
return invalidChunkUploadError("invalid chunk index")
}
if chunk.FileSize <= 0 || chunk.Offset < 0 || chunk.Offset > chunk.FileSize {
return invalidChunkUploadError("invalid upload offset")
}
chunkEnd := chunk.Offset + int64(len(chunkData))
if chunkEnd > chunk.FileSize {
return invalidChunkUploadError("chunk exceeds file size")
}
if chunk.ChunkIndex+1 == chunk.ChunkCount {
if chunkEnd != chunk.FileSize {
return invalidChunkUploadError("final chunk does not match file size")
}
} else if chunkEnd >= chunk.FileSize {
return invalidChunkUploadError("non-final chunk reaches file size")
}
if completed, ok := loadCompletedChunkUpload(chunk.UploadID); ok {
if completed.dstDir == dstDir && completed.filename == chunk.Filename && completed.fileSize == chunk.FileSize {
return nil
}
return invalidChunkUploadError("upload ID has already completed another file")
}
upload := completedChunkUpload{dstDir: dstDir, filename: chunk.Filename, fileSize: chunk.FileSize}
if err := registerActiveChunkUpload(chunk.UploadID, upload); err != nil {
return err
}
mode, err := files.GetParentMode(dstDir)
if err != nil {
return err
}
if err = os.MkdirAll(dstDir, mode); err != nil {
return err
}
dstDirInfo, err := os.Stat(dstDir)
if err != nil {
return err
}
if !dstDirInfo.IsDir() {
return invalidChunkUploadError(fmt.Sprintf("upload destination [%s] is not a directory", dstDir))
}
dstFile := filepath.Join(dstDir, chunk.Filename)
partFile := resumableUploadPartPath(dstDir, chunk.UploadID)
if dstFile == partFile {
return invalidChunkUploadError("filename conflicts with upload temporary file")
}
fileMode := dstDirInfo.Mode().Perm()
ownerInfo := dstDirInfo
if dstInfo, statErr := os.Stat(dstFile); statErr == nil {
if !chunk.Overwrite {
if err := discardActiveChunkUpload(chunk.UploadID, partFile); err != nil {
return errors.Join(os.ErrExist, err)
}
return os.ErrExist
}
fileMode = dstInfo.Mode().Perm()
ownerInfo = dstInfo
} else if !os.IsNotExist(statErr) {
return statErr
}
part, err := os.OpenFile(partFile, os.O_CREATE|os.O_RDWR, fileMode)
if err != nil {
return err
}
partClosed := false
defer func() {
if !partClosed {
_ = part.Close()
}
}()
if stat, statErr := part.Stat(); statErr != nil {
return statErr
} else if chunk.Offset > stat.Size() {
return invalidChunkUploadError(fmt.Sprintf("unexpected upload offset %d, current size is %d", chunk.Offset, stat.Size()))
} else if chunk.Offset < stat.Size() && chunkEnd > stat.Size() {
if err = part.Truncate(chunk.Offset); err != nil {
return err
}
}
if _, err = part.WriteAt(chunkData, chunk.Offset); err != nil {
return err
}
if chunk.ChunkIndex+1 != chunk.ChunkCount {
return nil
}
partInfo, err := part.Stat()
if err != nil {
return err
}
if partInfo.Size() != chunk.FileSize {
return invalidChunkUploadError(fmt.Sprintf("uploaded file size mismatch: expected %d, got %d", chunk.FileSize, partInfo.Size()))
}
if err = part.Close(); err != nil {
return err
}
partClosed = true
if err = os.Chmod(partFile, fileMode); err != nil {
return err
}
if stat, ok := ownerInfo.Sys().(*syscall.Stat_t); ok {
if err = os.Chown(partFile, int(stat.Uid), int(stat.Gid)); err != nil {
return err
}
}
if chunkUploadCancelled(chunk.UploadID) {
return errChunkUploadCancelled
}
if err = finalizeActiveChunkUpload(chunk.UploadID, partFile, dstFile, chunk.Overwrite); err != nil {
return err
}
markChunkUploadCompleted(chunk.UploadID, upload)
deleteActiveChunkUpload(chunk.UploadID)
return nil
}
func mergeChunks(fileName string, fileDir string, dstDir string, chunkCount int, overwrite bool) error { func mergeChunks(fileName string, fileDir string, dstDir string, chunkCount int, overwrite bool) error {
defer func() { defer func() {
_ = os.RemoveAll(fileDir) _ = os.RemoveAll(fileDir)
@@ -864,6 +1270,10 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if chunkCount <= 0 || chunkIndex < 0 || chunkIndex >= chunkCount {
helper.BadRequest(c, errors.New("invalid chunk index"))
return
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
tmpDir := path.Join(global.Dir.TmpDir, "upload") tmpDir := path.Join(global.Dir.TmpDir, "upload")
if !fileOp.Stat(tmpDir) { if !fileOp.Stat(tmpDir) {
@@ -873,8 +1283,30 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
} }
} }
filename := c.PostForm("filename") filename := c.PostForm("filename")
fileDir := filepath.Join(tmpDir, filename) if filename == "" || filepath.Base(filename) != filename || strings.ContainsAny(filename, `/\\`) {
if chunkIndex == 0 { helper.BadRequest(c, errors.New("invalid filename"))
return
}
uploadID := strings.TrimSpace(c.PostForm("uploadID"))
resumable := c.PostForm("fileSize") != "" || c.PostForm("offset") != ""
cancellable := uploadID != ""
if cancellable && (filepath.Base(uploadID) != uploadID || strings.ContainsAny(uploadID, `/\\`)) {
helper.BadRequest(c, errors.New("invalid upload ID"))
return
}
if resumable && !cancellable {
helper.BadRequest(c, errors.New("upload ID is required"))
return
}
if !cancellable {
uploadID = filename
}
fileDir := filepath.Join(tmpDir, uploadID)
if cancellable && chunkUploadCancelled(uploadID) {
helper.BadRequest(c, errChunkUploadCancelled)
return
}
if !resumable && chunkIndex == 0 {
if fileOp.Stat(fileDir) { if fileOp.Stat(fileDir) {
_ = fileOp.DeleteDir(fileDir) _ = fileOp.DeleteDir(fileDir)
} }
@@ -883,28 +1315,68 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
filePath := filepath.Join(fileDir, filename) filePath := filepath.Join(fileDir, filename)
defer func() { defer func() {
if err != nil { if !resumable && err != nil {
_ = os.Remove(fileDir) _ = os.RemoveAll(fileDir)
} }
}() }()
var ( chunkData, err := io.ReadAll(uploadFile)
emptyFile *os.File if err != nil {
chunkData []byte helper.InternalServer(c, buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err))
) return
}
if cancellable && chunkUploadCancelled(uploadID) {
err = errChunkUploadCancelled
helper.BadRequest(c, err)
return
}
if resumable {
offset, parseErr := strconv.ParseInt(c.PostForm("offset"), 10, 64)
if parseErr != nil {
helper.BadRequest(c, parseErr)
return
}
fileSize, parseErr := strconv.ParseInt(c.PostForm("fileSize"), 10, 64)
if parseErr != nil {
helper.BadRequest(c, parseErr)
return
}
overwrite := true
if ow := c.PostForm("overwrite"); ow != "" {
overwrite, _ = strconv.ParseBool(ow)
}
err = writeResumableUploadChunk(resumableUploadChunk{
UploadID: uploadID,
Filename: filename,
DstDir: c.PostForm("path"),
ChunkIndex: chunkIndex,
ChunkCount: chunkCount,
Offset: offset,
FileSize: fileSize,
Overwrite: overwrite,
}, chunkData)
if err != nil {
uploadErr := buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err)
helper.ErrorWithDetailAndData(c, http.StatusInternalServerError, "ErrInternalServer", uploadErr, gin.H{
"retryable": isRetryableChunkUploadError(err),
})
return
}
if chunkIndex+1 == chunkCount {
cancelledChunkUploads.Lock()
delete(cancelledChunkUploads.ids, uploadID)
cancelledChunkUploads.Unlock()
}
helper.SuccessWithData(c, true)
return
}
emptyFile, err = os.Create(filePath) emptyFile, err := os.Create(filePath)
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
defer emptyFile.Close() defer emptyFile.Close()
chunkData, err = io.ReadAll(uploadFile)
if err != nil {
helper.InternalServer(c, buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err))
return
}
chunkPath := filepath.Join(fileDir, fmt.Sprintf("%s.%d", filename, chunkIndex)) chunkPath := filepath.Join(fileDir, fmt.Sprintf("%s.%d", filename, chunkIndex))
err = os.WriteFile(chunkPath, chunkData, constant.DirPerm) err = os.WriteFile(chunkPath, chunkData, constant.DirPerm)
if err != nil { if err != nil {
@@ -922,12 +1394,54 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
helper.InternalServer(c, buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err)) helper.InternalServer(c, buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err))
return return
} }
if cancellable {
cancelledChunkUploads.Lock()
delete(cancelledChunkUploads.ids, uploadID)
cancelledChunkUploads.Unlock()
}
helper.SuccessWithData(c, true) helper.SuccessWithData(c, true)
} else { } else {
return return
} }
} }
// StopChunkUpload removes temporary chunks left by a cancelled upload.
func (b *BaseApi) StopChunkUpload(c *gin.Context) {
var req request.FileProcessReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
uploadID := strings.TrimSpace(req.Key)
if uploadID == "" || filepath.Base(uploadID) != uploadID || strings.ContainsAny(uploadID, `/\\`) {
helper.BadRequest(c, errors.New("invalid upload ID"))
return
}
cancelledChunkUploads.Lock()
cancelledChunkUploads.ids[uploadID] = struct{}{}
cancelledChunkUploads.Unlock()
time.AfterFunc(10*time.Minute, func() {
cancelledChunkUploads.Lock()
delete(cancelledChunkUploads.ids, uploadID)
cancelledChunkUploads.Unlock()
})
if err := os.RemoveAll(filepath.Join(global.Dir.TmpDir, "upload", uploadID)); err != nil {
helper.InternalServer(c, err)
return
}
if err := removeActiveResumableUploadPart(uploadID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
func chunkUploadCancelled(uploadID string) bool {
cancelledChunkUploads.RLock()
_, ok := cancelledChunkUploads.ids[uploadID]
cancelledChunkUploads.RUnlock()
return ok
}
var wsUpgrade = websocket.Upgrader{ var wsUpgrade = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { CheckOrigin: func(r *http.Request) bool {
return true return true
+636 -246
View File
@@ -1,26 +1,53 @@
package v2 package v2
import ( import (
"errors"
"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/utils/firewall/filter"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func (b *BaseApi) UpdatePanelFirewallPort(c *gin.Context) {
if !global.IsMaster {
c.AbortWithStatus(http.StatusForbidden)
return
}
var request struct {
OldPort uint `json:"oldPort" validate:"required,min=1,max=65535"`
NewPort uint `json:"newPort" validate:"required,min=1,max=65535"`
}
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if err := firewallService.UpdatePanelPort(c.Request.Context(), request.OldPort, request.NewPort); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall // @Tags Firewall
// @Summary Load firewall base info // @Summary Load firewall base info
// @Accept json // @Accept json
// @Param request body dto.OperationWithName true "request" // @Param request body dto.OperationWithName true "request"
// @Success 200 {object} dto.FirewallBaseInfo // @Success 200 {object} dto.FirewallSubsystemStatus
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/base [post] // @Router /hosts/firewall/base [post]
func (b *BaseApi) LoadFirewallBaseInfo(c *gin.Context) { func (b *BaseApi) LoadFirewallBaseInfo(c *gin.Context) {
var req dto.OperationWithName var request dto.OperationWithName
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
data, err := firewallService.LoadBaseInfo(req.Name) data, err := firewallService.LoadBaseInfo(request.Name)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -29,311 +56,674 @@ func (b *BaseApi) LoadFirewallBaseInfo(c *gin.Context) {
helper.SuccessWithData(c, data) helper.SuccessWithData(c, data)
} }
// @Tags Firewall
// @Summary Page firewall rules
// @Accept json
// @Param request body dto.RuleSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/search [post]
func (b *BaseApi) SearchFirewallRule(c *gin.Context) {
var req dto.RuleSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := firewallService.SearchWithPage(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Items: list,
Total: total,
})
}
// @Tags Firewall // @Tags Firewall
// @Summary Operate firewall // @Summary Operate firewall
// @Accept json // @Accept json
// @Param request body dto.FirewallOperation true "request" // @Param request body dto.FirewallLifecycleOperation true "request"
// @Success 200 // @Success 200 {object} dto.FirewallLifecycleOperationResponse
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/operate [post] // @Router /hosts/firewall/operate [post]
// @x-panel-log {"bodyKeys":["operation"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operation] 防火墙","formatEN":"[operation] firewall"} // @x-panel-log {"bodyKeys":["operation"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operation] 防火墙","formatEN":"[operation] firewall"}
func (b *BaseApi) OperateFirewall(c *gin.Context) { func (b *BaseApi) OperateFirewall(c *gin.Context) {
var req dto.FirewallOperation var request dto.FirewallLifecycleOperation
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
if err := firewallService.OperateFirewall(req); err != nil { result, err := firewallService.QueueFirewallOperation(request)
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary Create group
// @Accept json
// @Param request body dto.PortRuleOperate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/port [post]
// @x-panel-log {"bodyKeys":["port","strategy"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"添加端口规则 [strategy] [port]","formatEN":"create port rules [strategy][port]"}
func (b *BaseApi) OperatePortRule(c *gin.Context) {
var req dto.PortRuleOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := firewallService.OperatePortRule(req, true); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// OperateForwardRule
// @Tags Firewall
// @Summary Operate forward rule
// @Accept json
// @Param request body dto.ForwardRuleOperate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/forward [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新端口转发规则","formatEN":"update port forward rules"}
func (b *BaseApi) OperateForwardRule(c *gin.Context) {
var req dto.ForwardRuleOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := firewallService.OperateForwardRule(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary Operate Ip rule
// @Accept json
// @Param request body dto.AddrRuleOperate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/ip [post]
// @x-panel-log {"bodyKeys":["strategy","address"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"添加 ip 规则 [strategy] [address]","formatEN":"create address rules [strategy][address]"}
func (b *BaseApi) OperateIPRule(c *gin.Context) {
var req dto.AddrRuleOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := firewallService.OperateAddressRule(req, true); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary Batch operate rule
// @Accept json
// @Param request body dto.BatchRuleOperate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/batch [post]
func (b *BaseApi) BatchOperateRule(c *gin.Context) {
var req dto.BatchRuleOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := firewallService.BatchOperateRule(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary Update rule description
// @Accept json
// @Param request body dto.UpdateFirewallDescription true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/update/description [post]
func (b *BaseApi) UpdateFirewallDescription(c *gin.Context) {
var req dto.UpdateFirewallDescription
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := firewallService.UpdateDescription(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary Update port rule
// @Accept json
// @Param request body dto.PortRuleUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/update/port [post]
func (b *BaseApi) UpdatePortRule(c *gin.Context) {
var req dto.PortRuleUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := firewallService.UpdatePortRule(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary Update Ip rule
// @Accept json
// @Param request body dto.AddrRuleUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/update/addr [post]
func (b *BaseApi) UpdateAddrRule(c *gin.Context) {
var req dto.AddrRuleUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := firewallService.UpdateAddrRule(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary search iptables filter rules
// @Accept json
// @Param request body dto.SearchPageWithType true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/filter/rule/search [post]
func (b *BaseApi) SearchFilterRules(c *gin.Context) {
var req dto.SearchPageWithType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := iptablesService.Search(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, dto.PageResult{ helper.SuccessWithData(c, result)
Items: list,
Total: total,
})
} }
// @Tags Firewall // @Tags Firewall
// @Summary Operate iptables filter rule // @Summary Load forwarding base info
// @Accept json // @Accept json
// @Param request body dto.IptablesRuleOp true "request" // @Success 200 {object} dto.FirewallSubsystemStatus
// @Success 200
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/filter/rule/operate [post] // @Router /hosts/firewall/forward/base [post]
// @x-panel-log {"bodyKeys":["operation","chain"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operation] filter规则到 [chain]","formatEN":"[operation] filter rule to [chain]"} func (b *BaseApi) LoadForwardingBaseInfo(c *gin.Context) {
func (b *BaseApi) OperateFilterRule(c *gin.Context) { data, err := forwardingService.LoadBaseInfo()
var req dto.IptablesRuleOp if err != nil {
if err := helper.CheckBindAndValidate(&req, c); err != nil { helper.InternalServer(c, err)
return return
} }
if err := iptablesService.OperateRule(req, true); err != nil { helper.SuccessWithData(c, data)
}
// @Tags Firewall
// @Summary Page forwarding rules
// @Accept json
// @Param request body dto.ForwardRuleSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/forward/search [post]
func (b *BaseApi) SearchForwardingRules(c *gin.Context) {
var request dto.ForwardRuleSearch
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
total, items, err := forwardingService.SearchRules(request)
if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.Success(c) helper.SuccessWithData(c, dto.PageResult{Items: items, Total: total})
} }
// @Tags Firewall // @Tags Firewall
// @Summary Batch operate iptables filter rules // @Summary Operate forwarding rules
// @Accept json // @Accept json
// @Param request body dto.IptablesBatchOperate true "request" // @Param request body dto.ForwardRuleOperate true "request"
// @Success 200 // @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/filter/rule/batch [post] // @Router /hosts/firewall/forward/operate [post]
func (b *BaseApi) BatchOperateFilterRule(c *gin.Context) { // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新端口转发规则","formatEN":"update port forward rules"}
var req dto.IptablesBatchOperate func (b *BaseApi) OperateForwardingRules(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil { var request dto.ForwardRuleOperate
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
if err := iptablesService.BatchOperate(req); err != nil { result, err := forwardingService.OperateRules(request)
if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, result)
helper.Success(c)
} }
// @Tags Firewall // @Tags Firewall
// @Summary Apply/Unload/Init iptables filter // @Summary Enable forwarding
// @Accept json // @Accept json
// @Param request body dto.IptablesOp true "request" // @Param request body dto.FirewallInitializationTask true "request"
// @Success 200 // @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/forward/enable [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"初始化并启用端口转发","formatEN":"initialize and enable port forwarding"}
func (b *BaseApi) EnableForwarding(c *gin.Context) {
var request dto.FirewallInitializationTask
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := forwardingService.QueueInitialization(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Apply/Unload/Init firewall filter chain
// @Accept json
// @Param request body dto.FilterChainOperation true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/filter/operate [post] // @Router /hosts/firewall/filter/operate [post]
// @x-panel-log {"bodyKeys":["operate"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operate] iptables filter 防火墙","formatEN":"[operate] iptables filter firewall"} // @x-panel-log {"bodyKeys":["operate"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operate] 防火墙过滤链","formatEN":"[operate] firewall filter chain"}
func (b *BaseApi) OperateFilterChain(c *gin.Context) { func (b *BaseApi) OperateFilterChain(c *gin.Context) {
var req dto.IptablesOp var request dto.FilterChainOperation
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
if err := iptablesService.Operate(req); err != nil { if request.Operate == "init-base" {
result, err := firewallService.QueueFilterChainInitialization(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
return
}
if err := firewallService.OperateFilterChain(request); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, dto.FilterChainOperationResponse{})
}
// @Tags Firewall
// @Summary List unified firewall v2 rules
// @Accept json
// @Param request body dto.FirewallRuleInventory true "request"
// @Success 200 {object} dto.FirewallRuleInventoryResponse
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/search [post]
func (b *BaseApi) SearchFirewallRules(c *gin.Context) {
var request dto.FirewallRuleInventory
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
inventory, err := firewallService.Inventory(c.Request.Context(), request)
if err != nil {
handleFirewallRuleError(c, err)
return
}
helper.SuccessWithData(c, inventory)
}
// @Tags Firewall
// @Summary Reset firewall rules
// @Accept json
// @Param request body dto.FirewallRuleReset true "request"
// @Success 200 {object} dto.FirewallRuleResetResponse
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/reset [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"重置防火墙规则","formatEN":"reset firewall rules"}
func (b *BaseApi) ResetFirewallRules(c *gin.Context) {
var request dto.FirewallRuleReset
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallService.Reset(c.Request.Context(), request)
if err != nil {
handleFirewallRuleError(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Load one provider-native firewall object definition
// @Accept json
// @Param request body dto.FirewallNativeDetail true "request"
// @Success 200 {string} string
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/native/detail [post]
func (b *BaseApi) LoadFirewallNativeDetail(c *gin.Context) {
var request dto.FirewallNativeDetail
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
info, err := firewallService.LoadFirewallNativeDetail(c.Request.Context(), request)
if err != nil {
handleFirewallRuleError(c, err)
return
}
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) helper.Success(c)
} }
// @Tags Firewall // @Tags Firewall
// @Summary load chain status with name // @Summary Queue firewall rule creation
// @Description Creation and import return a taskID immediately; validation and execution results are written to the task log.
// @Accept json // @Accept json
// @Param request body dto.OperationWithName true "request" // @Param request body dto.FirewallRuleCreate true "request"
// @Success 200 {object} dto.FirewallRuleCreateResponse
// @Failure 400 {object} dto.Response
// @Failure 409 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"添加防火墙规则","formatEN":"create firewall rules"}
func (b *BaseApi) CreateFirewallRules(c *gin.Context) {
var request dto.FirewallRuleCreate
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallService.Create(c.Request.Context(), request)
if err != nil {
handleFirewallRuleError(c, err)
return
}
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
// @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.
// @Accept json
// @Param request body dto.FirewallRuleDelete true "request"
// @Success 200 {object} dto.FirewallRuleDeleteResponse
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/delete [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除防火墙规则","formatEN":"delete firewall rules"}
func (b *BaseApi) DeleteFirewallRules(c *gin.Context) {
var request dto.FirewallRuleDelete
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallService.Delete(c.Request.Context(), request)
if err != nil {
handleFirewallRuleError(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Update a managed unified firewall v2 rule
// @Accept json
// @Param request body dto.FirewallRuleUpdate true "request"
// @Success 200
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/update [post]
// @x-panel-log {"bodyKeys":["uuid"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新防火墙规则 [uuid]","formatEN":"update firewall rule [uuid]"}
func (b *BaseApi) UpdateFirewallRule(c *gin.Context) {
var request dto.FirewallRuleUpdate
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if !normalizeFirewallRuleUUID(c, &request.UUID) {
return
}
if err := firewallService.Update(c.Request.Context(), c.ClientIP(), request); err != nil {
handleFirewallRuleError(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary Reorder a managed unified firewall v2 rule
// @Accept json
// @Param request body dto.FirewallRuleReorder true "request"
// @Success 200
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/reorder [post]
// @x-panel-log {"bodyKeys":["uuid"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"调整防火墙规则顺序 [uuid]","formatEN":"reorder firewall rule [uuid]"}
func (b *BaseApi) ReorderFirewallRule(c *gin.Context) {
var request dto.FirewallRuleReorder
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if !normalizeFirewallRuleUUID(c, &request.UUID) {
return
}
if err := firewallService.Reorder(c.Request.Context(), c.ClientIP(), request); err != nil {
handleFirewallRuleError(c, err)
return
}
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) {
switch {
case errors.Is(err, filter.ErrProtectedRule):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_LOCKOUT_RISK", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrRuleStale):
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),
errors.Is(err, filter.ErrProviderUnavailable), errors.Is(err, filter.ErrAdapterUnavailable):
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),
errors.Is(err, repo.ErrFirewallPersistenceInvalid):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_RULE_UNSUPPORTED", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrVerificationFailed):
helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_VERIFY_FAILED", "ErrInternalServer", err)
default:
helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_APPLY_FAILED", "ErrInternalServer", err)
}
}
// @Tags Firewall
// @Summary Load firewall settings
// @Success 200 {object} dto.FirewallSettings
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/settings [get]
func (b *BaseApi) LoadFirewallSettings(c *gin.Context) {
data, err := firewallSettingService.Load(c.Request.Context())
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Firewall
// @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.
// @Accept json
// @Param request body dto.FirewallPortWhitelistCreate true "request"
// @Success 200 // @Success 200
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/filter/chain/status [post] // @Router /hosts/firewall/settings/whitelist [post]
func (b *BaseApi) LoadChainStatus(c *gin.Context) { // @x-panel-log {"bodyKeys":["rule"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建防火墙端口白名单","formatEN":"create firewall port whitelist"}
var req dto.OperationWithName func (b *BaseApi) CreateFirewallPortWhitelist(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil { var request dto.FirewallPortWhitelistCreate
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
if err := firewallSettingService.CreatePortWhitelist(c.Request.Context(), request); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
helper.SuccessWithData(c, iptablesService.LoadChainStatus(req)) // @Tags Firewall
// @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.
// @Accept json
// @Param request body dto.FirewallPortWhitelistUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/settings/whitelist/update [post]
// @x-panel-log {"bodyKeys":["oldRule","rule"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"编辑防火墙端口白名单","formatEN":"update firewall port whitelist"}
func (b *BaseApi) UpdateFirewallPortWhitelist(c *gin.Context) {
var request dto.FirewallPortWhitelistUpdate
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if err := firewallSettingService.UpdatePortWhitelist(c.Request.Context(), request); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @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.
// @Accept json
// @Param request body dto.FirewallPortWhitelistDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/settings/whitelist/delete [post]
// @x-panel-log {"bodyKeys":["rules"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除防火墙端口白名单","formatEN":"delete firewall port whitelist"}
func (b *BaseApi) DeleteFirewallPortWhitelist(c *gin.Context) {
var request dto.FirewallPortWhitelistDelete
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if err := firewallSettingService.DeletePortWhitelist(c.Request.Context(), request); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary Operate firewall backend
// @Accept json
// @Param request body dto.FirewallBackendOperation true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/settings/operate [post]
// @x-panel-log {"bodyKeys":["subsystem","backend","operation"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"防火墙子系统 [subsystem] 后端 [operation] [backend]","formatEN":"[operation] firewall [subsystem] backend [backend]"}
func (b *BaseApi) OperateFirewallBackend(c *gin.Context) {
var request dto.FirewallBackendOperation
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if err := firewallSettingService.Operate(c.Request.Context(), request); err != nil {
if errors.Is(err, service.ErrFirewallBackendCleanupRequired) {
helper.ErrorWithBusinessCode(
c,
http.StatusConflict,
"FW_BACKEND_CLEANUP_REQUIRED",
"ErrInvalidParams",
err,
)
return
}
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary List Docker port guard status and policies
// @Success 200 {object} dto.DockerPortGuardList
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/docker/ports [get]
func (b *BaseApi) ListDockerPortGuard(c *gin.Context) {
data, err := dockerPortGuardService.LoadOverview(c.Request.Context())
if err != nil {
handleDockerPortGuardError(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Firewall
// @Summary List Docker published ports
// @Success 200 {array} dto.DockerPortGuardContainer
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/docker/endpoints [get]
func (b *BaseApi) ListDockerPublishedPorts(c *gin.Context) {
data, err := dockerPortGuardService.LoadPublishedPorts(c.Request.Context())
if err != nil {
handleDockerPortGuardError(c, err)
return
}
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
// @Summary Operate Docker port guard
// @Accept json
// @Param request body dto.DockerPortGuardOperation true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/docker/operate [post]
// @x-panel-log {"bodyKeys":["operation"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operation] Docker 端口防护","formatEN":"[operation] Docker port guard"}
func (b *BaseApi) OperateDockerPortGuard(c *gin.Context) {
var request dto.DockerPortGuardOperation
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if request.Operation == "initialize" {
result, err := dockerPortGuardService.QueueInitialization(request)
if err != nil {
handleDockerPortGuardError(c, err)
return
}
helper.SuccessWithData(c, result)
return
}
if err := dockerPortGuardService.Operate(c.Request.Context(), request); err != nil {
handleDockerPortGuardError(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall
// @Summary Delete Docker port guard policies
// @Accept json
// @Param request body dto.DockerPortGuardPolicyBatchDelete true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @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]"}
func (b *BaseApi) DeleteDockerPortGuardPolicies(c *gin.Context) {
var request dto.DockerPortGuardPolicyBatchDelete
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := dockerPortGuardService.DeletePolicies(request)
if err != nil {
handleDockerPortGuardError(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Batch upsert Docker port guard policies
// @Accept json
// @Param request body dto.DockerPortGuardPolicyBatch true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/docker/policies/batch [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"批量更新 Docker 端口防护策略","formatEN":"batch update Docker port guard policies"}
func (b *BaseApi) UpsertDockerPortGuardPolicies(c *gin.Context) {
var request dto.DockerPortGuardPolicyBatch
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := dockerPortGuardService.UpsertPolicies(request)
if err != nil {
handleDockerPortGuardError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func handleDockerPortGuardError(c *gin.Context, err error) {
if errors.Is(err, service.ErrDockerIptablesChainUnavailable) {
helper.ErrorWithBusinessCode(c, http.StatusServiceUnavailable, "FW_DOCKER_IPTABLES_CHAIN_UNAVAILABLE", "ErrDockerIptablesChainUnavailable", err)
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) {
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)
return
}
helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_DOCKER_GUARD_FAILED", "ErrInternalServer", err)
} }
+8 -16
View File
@@ -3,9 +3,8 @@ package v2
import ( import (
"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/utils/ai_tools/gpu" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/gpu/common" "github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/xpu"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -17,27 +16,20 @@ import (
// @Security Timestamp // @Security Timestamp
// @Router /ai/gpu/load [get] // @Router /ai/gpu/load [get]
func (b *BaseApi) LoadGpuInfo(c *gin.Context) { func (b *BaseApi) LoadGpuInfo(c *gin.Context) {
ok, client := gpu.New() ok, client := accelerator.New()
if ok { if ok {
info, err := client.LoadGpuInfo() snapshot, err := client.Collect(c.Request.Context())
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
helper.SuccessWithData(c, info) if warning := snapshot.Warning(); warning != nil {
return global.LOG.Warnf("load realtime accelerator data partially failed, err: %v", warning)
}
xpuOK, xpuClient := xpu.New()
if xpuOK {
info, err := xpuClient.LoadGpuInfo()
if err != nil {
helper.BadRequest(c, err)
return
} }
helper.SuccessWithData(c, info) helper.SuccessWithData(c, &snapshot.Info)
return return
} }
helper.SuccessWithData(c, &common.GpuInfo{}) helper.SuccessWithData(c, &accelerator.Info{})
} }
// @Tags AI // @Tags AI
+20
View File
@@ -30,6 +30,26 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
ctx.Abort() ctx.Abort()
} }
func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
res := dto.Response{
Code: code,
ErrorCode: businessCode,
Message: i18n.GetMsgWithDetail(msgKey, err.Error()),
}
ctx.JSON(http.StatusOK, res)
ctx.Abort()
}
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
res := dto.Response{
Code: code,
Data: data,
}
res.Message = i18n.GetMsgWithDetail(msgKey, err.Error())
ctx.JSON(http.StatusOK, res)
ctx.Abort()
}
func InternalServer(ctx *gin.Context, err error) { func InternalServer(ctx *gin.Context, err error) {
ErrorWithDetail(ctx, http.StatusInternalServerError, "ErrInternalServer", err) ErrorWithDetail(ctx, http.StatusInternalServerError, "ErrInternalServer", err)
} }
+55
View File
@@ -2,6 +2,7 @@ package v2
import ( import (
"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/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -20,3 +21,57 @@ func (b *BaseApi) GetSystemFiles(c *gin.Context) {
helper.SuccessWithData(c, data) helper.SuccessWithData(c, data)
} }
// @Tags Logs
// @Summary Get host system log status
// @Produce json
// @Success 200 {object} dto.SystemLogStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /logs/system/status [get]
func (b *BaseApi) GetSystemLogStatus(c *gin.Context) {
data, err := logService.GetSystemLogStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Logs
// @Summary Read host logs
// @Accept json
// @Param request body dto.SystemLogReq true "request"
// @Produce json
// @Success 200 {object} dto.SystemLogRes
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /logs/system/read [post]
func (b *BaseApi) ReadSystemLog(c *gin.Context) {
var req dto.SystemLogReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := logService.ReadSystemLog(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Logs
// @Summary List running host services
// @Produce json
// @Success 200 {array} string
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /logs/system/services [get]
func (b *BaseApi) ListRunningServices(c *gin.Context) {
data, err := logService.ListRunningServices()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
+63
View File
@@ -23,6 +23,27 @@ func (b *BaseApi) PageMcpServers(c *gin.Context) {
helper.SuccessWithData(c, list) helper.SuccessWithData(c, list)
} }
// @Tags McpServer
// @Summary Load mcp server detail
// @Accept json
// @Param request body request.McpServerDetail true "request"
// @Success 200 {object} response.McpServerDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/mcp/server/detail [post]
func (b *BaseApi) LoadMcpServerDetail(c *gin.Context) {
var req request.McpServerDetail
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := mcpServerService.Detail(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags McpServer // @Tags McpServer
// @Summary Create mcp server // @Summary Create mcp server
// @Accept json // @Accept json
@@ -107,6 +128,48 @@ func (b *BaseApi) OperateMcpServer(c *gin.Context) {
helper.Success(c) helper.Success(c)
} }
// @Tags McpServer
// @Summary Sync mcp server status
// @Accept json
// @Param request body request.McpServerStatusSync true "request"
// @Success 200 {array} response.McpServerStatusDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/mcp/server/status/sync [post]
func (b *BaseApi) SyncMcpServerStatus(c *gin.Context) {
var req request.McpServerStatusSync
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := mcpServerService.SyncStatus(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags McpServer
// @Summary Test mcp server connection
// @Accept json
// @Param request body request.McpServerConnectionTest true "request"
// @Success 200 {object} response.McpServerConnectionTestRes
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/mcp/server/connection/test [post]
func (b *BaseApi) TestMcpServerConnection(c *gin.Context) {
var req request.McpServerConnectionTest
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := mcpServerService.TestConnection(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags McpServer // @Tags McpServer
// @Summary Bind Domain for mcp server // @Summary Bind Domain for mcp server
// @Accept json // @Accept json
+63
View File
@@ -0,0 +1,63 @@
package v2
import (
"os"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
// @Tags RuntimeDiagnostics
// @Summary Load runtime diagnostics summary
// @Success 200 {object} dto.RuntimeDiagnosticsSummary
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/diagnostics/summary [get]
func (b *BaseApi) LoadRuntimeDiagnosticsSummary(c *gin.Context) {
data, err := runtimeDiagnosticsService.Summary()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags RuntimeDiagnostics
// @Summary Load grouped goroutine snapshot
// @Success 200 {object} dto.RuntimeGoroutineSnapshot
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/diagnostics/goroutines [get]
func (b *BaseApi) LoadRuntimeGoroutines(c *gin.Context) {
data, err := runtimeDiagnosticsService.Goroutines()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithDataGzipped(c, data)
}
// @Tags RuntimeDiagnostics
// @Summary Capture runtime profile
// @Param request body dto.RuntimeProfileCreate true "request"
// @Success 200 {file} file
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/diagnostics/profiles [post]
func (b *BaseApi) CreateRuntimeProfile(c *gin.Context) {
var req dto.RuntimeProfileCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
profile, err := runtimeDiagnosticsService.CreateProfile(req)
if err != nil {
helper.BadRequest(c, err)
return
}
defer os.Remove(profile.Path)
c.Header("Content-Disposition", `attachment; filename="`+profile.Name+`"`)
c.Header("Content-Type", "application/octet-stream")
c.File(profile.Path)
c.Abort()
}
+15
View File
@@ -224,6 +224,21 @@ func (b *BaseApi) ExportSSHLogs(c *gin.Context) {
helper.SuccessWithData(c, tmpFile) helper.SuccessWithData(c, tmpFile)
} }
// @Tags SSH
// @Summary Clean host SSH logs
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/ssh/log/clean [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"清空 SSH 登录日志","formatEN":"clean SSH login logs"}
func (b *BaseApi) CleanSSHLogs(c *gin.Context) {
if err := sshService.CleanLog(); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags SSH // @Tags SSH
// @Summary Load host SSH conf // @Summary Load host SSH conf
// @Accept json // @Accept json
+143 -31
View File
@@ -1,11 +1,14 @@
package v2 package v2
import ( import (
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
@@ -19,29 +22,35 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/pkg/errors" "github.com/pkg/errors"
gossh "golang.org/x/crypto/ssh"
) )
// @Tags Terminal // @Tags Terminal
// @Summary Ws local terminal // @Summary Ws local terminal
// @Param command query string false "command" // @Param command query string false "command"
// @Param session query string false "session id to reattach"
// @Param terminalPersistent query boolean false "allow recovery after an unexpected disconnect"
// @Success 200 // @Success 200
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/terminal/local [get] // @Router /hosts/terminal/local [get]
func (b *BaseApi) WsLocalTerminal(c *gin.Context) { func (b *BaseApi) WsLocalTerminal(c *gin.Context) {
b.runSSHSession(c, loadLocalConn, c.DefaultQuery("command", "")) b.runSSHSession(c, "local", loadLocalConn, c.DefaultQuery("command", ""))
} }
// @Tags Terminal // @Tags Terminal
// @Summary Ws host SSH // @Summary Ws host SSH
// @Param id query integer false "id" // @Param id query integer false "id"
// @Param command query string false "command" // @Param command query string false "command"
// @Param session query string false "session id to reattach"
// @Param title query string false "session title shown in the session list"
// @Param terminalPersistent query boolean false "allow recovery after an unexpected disconnect"
// @Success 200 // @Success 200
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/terminal/ssh [get] // @Router /hosts/terminal/ssh [get]
func (b *BaseApi) WsHostSSH(c *gin.Context) { func (b *BaseApi) WsHostSSH(c *gin.Context) {
b.runSSHSession(c, func() (*ssh.SSHClient, error) { b.runSSHSession(c, "ssh", func() (*ssh.SSHClient, error) {
hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0")) hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0"))
if hostID <= 0 { if hostID <= 0 {
return nil, errors.New("missing host id") return nil, errors.New("missing host id")
@@ -65,26 +74,33 @@ func (b *BaseApi) WsContainerTerminal(c *gin.Context) {
return return
} }
defer wsConn.Close() defer wsConn.Close()
identity, ok := loadTerminalIdentity(c)
slave, err := loadContainerTerminalCommand(c) if !ok {
if wshandleError(wsConn, err) { _ = wshandleError(wsConn, errors.New("missing terminal identity"))
return
}
defer slave.Close()
tty, err := terminal.NewLocalWsSession(cols, rows, wsConn, slave, false)
if wshandleError(wsConn, err) {
return return
} }
quitChan := make(chan bool, 3) opts := terminal.SessionOptions{
tty.Start(quitChan) Identity: identity,
go slave.Wait(quitChan) Kind: "container",
Target: containerTerminalTarget(c),
Cols: cols,
Rows: rows,
}
if err := terminal.ServeCommand(wsConn, strings.TrimSpace(c.Query("session")), opts, func() (*terminal.LocalCommand, error) {
return loadContainerTerminalCommand(c)
}); err != nil {
_ = wshandleError(wsConn, err)
}
}
<-quitChan func containerTerminalTarget(c *gin.Context) string {
query := c.Request.URL.Query()
global.LOG.Info("websocket finished") for _, key := range []string{"cols", "rows", "session", "terminalRevalidate"} {
closeTerminalConn(wsConn) query.Del(key)
}
sum := sha256.Sum256([]byte(query.Encode()))
return hex.EncodeToString(sum[:])
} }
func prepareTerminalSession(c *gin.Context) (*websocket.Conn, int, int, bool) { func prepareTerminalSession(c *gin.Context) (*websocket.Conn, int, int, bool) {
@@ -115,32 +131,128 @@ func prepareTerminalSession(c *gin.Context) (*websocket.Conn, int, int, bool) {
return wsConn, cols, rows, true return wsConn, cols, rows, true
} }
func (b *BaseApi) runSSHSession(c *gin.Context, connect func() (*ssh.SSHClient, error), command string) { func (b *BaseApi) runSSHSession(c *gin.Context, kind string, connect func() (*ssh.SSHClient, error), command string) {
wsConn, cols, rows, ok := prepareTerminalSession(c) wsConn, cols, rows, ok := prepareTerminalSession(c)
if !ok { if !ok {
return return
} }
defer wsConn.Close() defer wsConn.Close()
identity, ok := loadTerminalIdentity(c)
client, clientErr := connect() if !ok {
if wshandleError(wsConn, errors.WithMessage(clientErr, "failed to set up the connection. Please check the host information")) { _ = wshandleError(wsConn, errors.New("missing terminal identity"))
return return
} }
defer client.Close()
sws, err := terminal.NewLogicSshWsSession(cols, rows, client.Client, wsConn, command) hostID := 0
if wshandleError(wsConn, err) { if kind == "ssh" {
hostID, _ = strconv.Atoi(c.DefaultQuery("id", "0"))
}
opts := terminal.SessionOptions{
Identity: identity,
Kind: kind,
Title: sanitizeTerminalTitle(c.Query("title")),
Persistent: c.Query("terminalPersistent") == "true",
HostID: uint(max(hostID, 0)),
Cols: cols,
Rows: rows,
InitCmd: command,
}
err := terminal.Serve(wsConn, strings.TrimSpace(c.Query("session")), opts, func() (*gossh.Client, error) {
client, err := connect()
if err != nil {
return nil, errors.WithMessage(err, "failed to set up the connection. Please check the host information")
}
return client.Client, nil
})
if err != nil {
_ = wshandleError(wsConn, err)
}
}
// @Tags Terminal
// @Summary List the caller's live terminal sessions
// @Success 200 {array} terminal.Info
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/terminal/sessions/search [post]
func (b *BaseApi) SearchTerminalSessions(c *gin.Context) {
identity, ok := loadTerminalIdentity(c)
if !ok {
helper.BadRequest(c, errors.New("missing terminal identity"))
return return
} }
defer sws.Close() helper.SuccessWithData(c, terminal.List(identity))
}
quitChan := make(chan bool, 3) // @Tags Terminal
sws.Start(quitChan) // @Summary Close a terminal session
go sws.Wait(quitChan) // @Accept json
// @Param request body dto.TerminalSessionClose true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/terminal/sessions/close [post]
func (b *BaseApi) CloseTerminalSession(c *gin.Context) {
var req dto.TerminalSessionClose
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
identity, ok := loadTerminalIdentity(c)
if !ok {
helper.BadRequest(c, errors.New("missing terminal identity"))
return
}
if err := terminal.CloseSession(req.ID, identity); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
<-quitChan // @Tags Terminal
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/terminal/sessions/closeAll [post]
func (b *BaseApi) CloseAllTerminalSessions(c *gin.Context) {
identity, ok := loadTerminalIdentity(c)
if !ok {
helper.BadRequest(c, errors.New("missing terminal identity"))
return
}
terminal.Revoke("auth_session", identity.UserID, identity.AuthSessionID)
helper.Success(c)
}
closeTerminalConn(wsConn) func (b *BaseApi) RevokeTerminalSessions(c *gin.Context) {
var req dto.TerminalSessionRevoke
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if (req.Scope == "auth_session" && (req.UserID == "" || req.AuthSessionID == "")) ||
(req.Scope == "user" && req.UserID == "") {
helper.BadRequest(c, errors.New("missing terminal revocation identity"))
return
}
terminal.Revoke(req.Scope, req.UserID, req.AuthSessionID)
helper.Success(c)
}
func loadTerminalIdentity(c *gin.Context) (terminal.Identity, bool) {
identity := terminal.Identity{
UserID: strings.TrimSpace(c.GetHeader(terminal.HeaderUserID)),
AuthSessionID: strings.TrimSpace(c.GetHeader(terminal.HeaderAuthSessionID)),
}
return identity, identity.Valid()
}
// sanitizeTerminalTitle keeps the title a short single line.
func sanitizeTerminalTitle(title string) string {
title = strings.Join(strings.Fields(title), " ")
if r := []rune(title); len(r) > 64 {
title = string(r[:64])
}
return title
} }
func closeTerminalConn(wsConn *websocket.Conn) { func closeTerminalConn(wsConn *websocket.Conn) {
+25
View File
@@ -210,6 +210,27 @@ func (b *BaseApi) UpdateWebsiteSSL(c *gin.Context) {
helper.Success(c) helper.Success(c)
} }
// @Tags Website SSL
// @Summary Push ssl to nodes
// @Accept json
// @Param request body request.WebsiteSSLPush true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ssl/push [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_ssls","output_column":"primary_domain","output_value":"domain"}],"formatZH":"推送证书到节点 [domain]","formatEN":"Push ssl to nodes [domain]"}
func (b *BaseApi) PushWebsiteSSLToNode(c *gin.Context) {
var req request.WebsiteSSLPush
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteSSLService.PushToNode(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website SSL // @Tags Website SSL
// @Summary Upload ssl // @Summary Upload ssl
// @Accept json // @Accept json
@@ -248,6 +269,8 @@ func (b *BaseApi) UploadSSLFile(c *gin.Context) {
var req request.WebsiteSSLFileUpload var req request.WebsiteSSLFileUpload
req.Description = c.PostForm("description") req.Description = c.PostForm("description")
req.Nodes = c.PostForm("nodes")
req.PushNode, _ = strconv.ParseBool(c.PostForm("pushNode"))
sslID := c.PostForm("sslID") sslID := c.PostForm("sslID")
if sslID != "" { if sslID != "" {
req.SSLID, _ = strconv.ParseUint(sslID, 10, 64) req.SSLID, _ = strconv.ParseUint(sslID, 10, 64)
@@ -283,6 +306,8 @@ func (b *BaseApi) UploadSSLFile(c *gin.Context) {
Certificate: string(certificateContent), Certificate: string(certificateContent),
Description: req.Description, Description: req.Description,
SSLID: uint(req.SSLID), SSLID: uint(req.SSLID),
PushNode: req.PushNode,
Nodes: req.Nodes,
} }
if err := websiteSSLService.Upload(uploadReq); err != nil { if err := websiteSSLService.Upload(uploadReq); err != nil {
+259
View File
@@ -0,0 +1,259 @@
package v2
import (
"io"
"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/request"
"github.com/gin-gonic/gin"
)
// @Tags Website Template
// @Summary Page website templates
// @Accept json
// @Param request body request.WebsiteTemplateSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/search [post]
func (b *BaseApi) PageWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, templates, err := websiteTemplateService.PageTemplate(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: templates,
})
}
// @Tags Website Template
// @Summary Create website template
// @Accept json
// @Param request body request.WebsiteTemplateCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建网站模板 [name]","formatEN":"Create website template [name]"}
func (b *BaseApi) CreateWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.CreateTemplate(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Update website template
// @Accept json
// @Param request body request.WebsiteTemplateUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/update [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新网站模板 [name]","formatEN":"Update website template [name]"}
func (b *BaseApi) UpdateWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.UpdateTemplate(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Delete website template
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_templates","output_column":"name","output_value":"name"}],"formatZH":"删除网站模板 [name]","formatEN":"Delete website template [name]"}
func (b *BaseApi) DeleteWebsiteTemplate(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.DeleteTemplate(req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Get website template
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200 {object} response.WebsiteTemplateDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/get [post]
func (b *BaseApi) GetWebsiteTemplate(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
template, err := websiteTemplateService.GetTemplate(req.ID)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, template)
}
// @Tags Website Template
// @Summary Upload website template zip
// @Accept multipart/form-data
// @Param file formData file true "file"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/upload [post]
func (b *BaseApi) UploadTemplateZip(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
helper.BadRequest(c, err)
return
}
file, err := fileHeader.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
helper.InternalServer(c, err)
return
}
filePath, variables, err := websiteTemplateService.SaveUploadZip(fileHeader.Filename, content)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, gin.H{"filePath": filePath, "variables": variables})
}
// @Tags Website Template
// @Summary Preview website template
// @Accept json
// @Param request body request.WebsitePreviewReq true "request"
// @Success 200 {object} response.WebsitePreviewDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/preview [post]
func (b *BaseApi) PreviewWebsiteTemplate(c *gin.Context) {
var req request.WebsitePreviewReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
preview, err := websiteTemplateService.Preview(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, preview)
}
// @Tags Website Template
// @Summary Page website template outputs
// @Accept json
// @Param request body request.WebsiteTemplateOutputSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/search [post]
func (b *BaseApi) PageWebsiteTemplateOutput(c *gin.Context) {
var req request.WebsiteTemplateOutputSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, outputs, err := websiteTemplateService.PageOutput(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: outputs,
})
}
// @Tags Website Template
// @Summary Create website template output
// @Accept json
// @Param request body request.WebsiteTemplateOutputCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成模板产物 [name]","formatEN":"Generate template output [name]"}
func (b *BaseApi) CreateWebsiteTemplateOutput(c *gin.Context) {
var req request.WebsiteTemplateOutputCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.CreateOutput(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Delete website template output
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_template_outputs","output_column":"name","output_value":"name"}],"formatZH":"删除模板产物 [name]","formatEN":"Delete template output [name]"}
func (b *BaseApi) DeleteWebsiteTemplateOutput(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.DeleteOutput(req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Get website template output
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200 {object} response.WebsiteTemplateOutputDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/get [post]
func (b *BaseApi) GetWebsiteTemplateOutput(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
output, err := websiteTemplateService.GetOutput(req.ID)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, output)
}
+159 -77
View File
@@ -3,28 +3,30 @@ package dto
import "time" import "time"
type AgentCreateReq struct { type AgentCreateReq struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Remark string `json:"remark"` Remark string `json:"remark"`
AppVersion string `json:"appVersion" validate:"required"` AppVersion string `json:"appVersion" validate:"required"`
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"`
Model string `json:"model"` Model string `json:"model"`
AccountID uint `json:"accountId"` AccountID uint `json:"accountId"`
Token string `json:"token"` Token string `json:"token"`
TaskID string `json:"taskID"` DashboardUsername string `json:"dashboardUsername"`
Advanced bool `json:"advanced"` DashboardPassword string `json:"dashboardPassword"`
ContainerName string `json:"containerName"` TaskID string `json:"taskID"`
AllowPort bool `json:"allowPort"` Advanced bool `json:"advanced"`
SpecifyIP string `json:"specifyIP"` ContainerName string `json:"containerName"`
RestartPolicy string `json:"restartPolicy"` AllowPort bool `json:"allowPort"`
CpuQuota float64 `json:"cpuQuota"` SpecifyIP string `json:"specifyIP"`
MemoryLimit float64 `json:"memoryLimit"` RestartPolicy string `json:"restartPolicy"`
MemoryUnit string `json:"memoryUnit"` CpuQuota float64 `json:"cpuQuota"`
PullImage bool `json:"pullImage"` MemoryLimit float64 `json:"memoryLimit"`
EditCompose bool `json:"editCompose"` MemoryUnit string `json:"memoryUnit"`
DockerCompose string `json:"dockerCompose"` PullImage bool `json:"pullImage"`
EditCompose bool `json:"editCompose"`
DockerCompose string `json:"dockerCompose"`
} }
type AgentBatchInstallReq struct { type AgentBatchInstallReq struct {
@@ -38,6 +40,8 @@ type AgentBatchInstallReq struct {
Model string `json:"model"` Model string `json:"model"`
AccountID uint `json:"accountId"` AccountID uint `json:"accountId"`
Token string `json:"token"` Token string `json:"token"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
Advanced bool `json:"advanced"` Advanced bool `json:"advanced"`
ContainerName string `json:"containerName"` ContainerName string `json:"containerName"`
@@ -114,11 +118,11 @@ type AgentItem struct {
ProviderName string `json:"providerName"` ProviderName string `json:"providerName"`
Model string `json:"model"` Model string `json:"model"`
APIType string `json:"apiType"` APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"` APIKey string `json:"apiKey"`
Token string `json:"token"` Token string `json:"token"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
AppInstallID uint `json:"appInstallId"` AppInstallID uint `json:"appInstallId"`
@@ -158,16 +162,25 @@ type AgentWebsiteBindReq struct {
} }
type AgentModelConfigUpdateReq struct { type AgentModelConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"` AgentID uint `json:"agentId" validate:"required"`
AccountID uint `json:"accountId" validate:"required"` AccountID uint `json:"accountId" validate:"required"`
Model string `json:"model" validate:"required"` Model string `json:"model" validate:"required"`
Fallbacks []string `json:"fallbacks"` Fallbacks []string `json:"fallbacks"`
Metadata []AgentModelMetadata `json:"metadata" validate:"dive"`
} }
type AgentModelConfig struct { type AgentModelConfig struct {
AccountID uint `json:"accountId"` AccountID uint `json:"accountId"`
Model string `json:"model"` Model string `json:"model"`
Fallbacks []string `json:"fallbacks"` Fallbacks []string `json:"fallbacks"`
Metadata []AgentModelMetadata `json:"metadata"`
}
type AgentModelMetadata struct {
Model string `json:"model" validate:"required"`
InputMode string `json:"inputMode" validate:"required,oneof=auto text image"`
ContextWindow int `json:"contextWindow" validate:"min=0"`
MaxTokens int `json:"maxTokens" validate:"min=0"`
} }
type AgentHermesChatSessionItem struct { type AgentHermesChatSessionItem struct {
@@ -286,19 +299,22 @@ type AgentOverviewSnapshot struct {
} }
type AgentAccountModel struct { type AgentAccountModel struct {
RecordID uint `json:"recordId"` RecordID uint `json:"recordId"`
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ContextWindow int `json:"contextWindow"`
MaxTokens int `json:"maxTokens"`
Reasoning bool `json:"reasoning"`
Input []string `json:"input"`
} }
type AgentAccountModelReq struct { type AgentAccountModelReq struct {
AccountID uint `json:"accountId" validate:"required"` AccountID uint `json:"accountId" validate:"required"`
} }
type AgentAccountModelDiscoverReq struct {
Provider string `json:"provider" validate:"required"`
BaseURL string `json:"baseURL" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
APIType string `json:"apiType" validate:"required"`
}
type AgentAccountModelCreateReq struct { type AgentAccountModelCreateReq struct {
AccountID uint `json:"accountId" validate:"required"` AccountID uint `json:"accountId" validate:"required"`
Model AgentAccountModel `json:"model" validate:"required"` Model AgentAccountModel `json:"model" validate:"required"`
@@ -315,31 +331,40 @@ type AgentAccountModelDeleteReq struct {
} }
type AgentAccountCreateReq struct { type AgentAccountCreateReq struct {
Provider string `json:"provider" validate:"required"` Provider string `json:"provider" validate:"required"`
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"` APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"` RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"` BaseURL string `json:"baseURL"`
Models []AgentAccountModel `json:"models"` Models []AgentAccountModel `json:"models"`
APIType string `json:"apiType" validate:"required"` APIType string `json:"apiType" validate:"required"`
Remark string `json:"remark"` AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
ValidateAvailability *bool `json:"validateAvailability"`
Remark string `json:"remark"`
} }
type AgentAccountUpdateReq struct { type AgentAccountUpdateReq struct {
ID uint `json:"id" validate:"required"` ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"` APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"` RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"` BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"` APIType string `json:"apiType" validate:"required"`
Remark string `json:"remark"` AuthMode string `json:"authMode"`
SyncAgents bool `json:"syncAgents"` VerifyModel string `json:"verifyModel"`
ValidateAvailability *bool `json:"validateAvailability"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
} }
type AgentAccountVerifyReq struct { type AgentAccountVerifyReq struct {
Provider string `json:"provider" validate:"required"` Provider string `json:"provider" validate:"required"`
APIKey string `json:"apiKey" validate:"required"` APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"` BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
Model string `json:"model"`
} }
type AgentAccountDeleteReq struct { type AgentAccountDeleteReq struct {
@@ -349,6 +374,8 @@ type AgentAccountDeleteReq struct {
type AgentAccountSearch struct { type AgentAccountSearch struct {
PageInfo PageInfo
Provider string `json:"provider"` Provider string `json:"provider"`
APIType string `json:"apiType"`
TextOnly bool `json:"textOnly"`
Name string `json:"name"` Name string `json:"name"`
} }
@@ -367,26 +394,36 @@ type AgentAccountInfo struct {
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
Models []AgentAccountModel `json:"models"` Models []AgentAccountModel `json:"models"`
APIType string `json:"apiType"` APIType string `json:"apiType"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
Verified bool `json:"verified"` Verified bool `json:"verified"`
Remark string `json:"remark"` Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type ProviderModelInfo struct { type ProviderModelInfo struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ContextWindow int `json:"contextWindow"` }
MaxTokens int `json:"maxTokens"`
Reasoning bool `json:"reasoning"` type ProviderAPIInfo struct {
Input []string `json:"input"` APIType string `json:"apiType"`
BaseURL string `json:"baseUrl"`
EditableBaseURL bool `json:"editableBaseUrl"`
SupportsModelDiscovery bool `json:"supportsModelDiscovery"`
DefaultAuthMode string `json:"defaultAuthMode"`
AuthModes []string `json:"authModes"`
Models []ProviderModelInfo `json:"models"`
} }
type ProviderInfo struct { type ProviderInfo struct {
Sort uint `json:"-"` Sort uint `json:"-"`
Provider string `json:"provider"` Provider string `json:"provider"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
Models []ProviderModelInfo `json:"models"` DefaultAPIType string `json:"defaultApiType"`
APITypes []ProviderAPIInfo `json:"apiTypes"`
Models []ProviderModelInfo `json:"models"`
} }
type AgentFeishuConfigReq struct { type AgentFeishuConfigReq struct {
@@ -407,11 +444,6 @@ type AgentFeishuConfigUpdateReq struct {
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"` Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
} }
type AgentFeishuPairingApproveReq struct {
AgentID uint `json:"agentId" validate:"required"`
PairingCode string `json:"pairingCode" validate:"required"`
}
type AgentFeishuConfig struct { type AgentFeishuConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"` ThreadSession bool `json:"threadSession"`
@@ -581,6 +613,52 @@ type AgentPluginStatus struct {
Upgradable bool `json:"upgradable"` Upgradable bool `json:"upgradable"`
} }
type AgentPluginsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentPluginSearchReq struct {
AgentID uint `json:"agentId" validate:"required"`
Keyword string `json:"keyword" validate:"required,max=100"`
Limit int `json:"limit" validate:"omitempty,min=1,max=100"`
}
type AgentPluginMarketInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Package string `json:"package" validate:"required,max=200"`
Version string `json:"version" validate:"required,max=100"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginOperateReq struct {
AgentID uint `json:"agentId" validate:"required"`
PluginID string `json:"pluginId" validate:"required,max=200"`
Operate string `json:"operate" validate:"required,oneof=enable disable update uninstall"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginItem struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Origin string `json:"origin"`
Enabled bool `json:"enabled"`
}
type AgentPluginSearchItem struct {
Package string `json:"package"`
PluginID string `json:"pluginId"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Channel string `json:"channel"`
VerificationTier string `json:"verificationTier"`
Categories []string `json:"categories"`
Official bool `json:"official"`
Downloads int64 `json:"downloads"`
Score float64 `json:"score"`
}
type AgentDiscordConfigUpdateReq struct { type AgentDiscordConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"` AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -660,16 +738,20 @@ type AgentSecurityConfig struct {
} }
type AgentOtherConfigUpdateReq struct { type AgentOtherConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"` AgentID uint `json:"agentId" validate:"required"`
UserTimezone string `json:"userTimezone" validate:"required"` UserTimezone string `json:"userTimezone"`
BrowserEnabled bool `json:"browserEnabled"` BrowserEnabled bool `json:"browserEnabled"`
NPMRegistry string `json:"npmRegistry" validate:"required"` NPMRegistry string `json:"npmRegistry"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
} }
type AgentOtherConfig struct { type AgentOtherConfig struct {
UserTimezone string `json:"userTimezone"` UserTimezone string `json:"userTimezone"`
BrowserEnabled bool `json:"browserEnabled"` BrowserEnabled bool `json:"browserEnabled"`
NPMRegistry string `json:"npmRegistry"` NPMRegistry string `json:"npmRegistry"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
} }
type AgentConfigFileReq struct { type AgentConfigFileReq struct {
+33 -18
View File
@@ -113,8 +113,10 @@ type DiskDTO struct {
type AlertLogSearch struct { type AlertLogSearch struct {
PageInfo PageInfo
Count uint `json:"count"` Count uint `json:"count"`
Status string `json:"status"` Status string `json:"status"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
} }
type AlertLogDTO struct { type AlertLogDTO struct {
@@ -149,16 +151,25 @@ type AlertLog struct {
} }
type AlertDetail struct { type AlertDetail struct {
LicenseId string `json:"licenseId"` LicenseId string `json:"licenseId"`
Type string `json:"type"` Type string `json:"type"`
SubType string `json:"subType"` SubType string `json:"subType"`
Title string `json:"title"` Title string `json:"title"`
Method string `json:"method"` Method string `json:"method"`
LicenseCode string `json:"licenseCode"` LicenseCode string `json:"licenseCode"`
DeviceId string `json:"deviceId"` DeviceId string `json:"deviceId"`
Project string `json:"project"` Project string `json:"project"`
Params []Param `json:"params"` Params []Param `json:"params"`
Phone string `json:"phone"` Phone string `json:"phone"`
Task *AlertTaskMetadata `json:"task,omitempty"`
}
type AlertTaskMetadata struct {
AlertID uint `json:"alertId"`
Type string `json:"type"`
Quota string `json:"quota"`
QuotaType string `json:"quotaType"`
Method string `json:"method"`
} }
type AlertRule struct { type AlertRule struct {
@@ -293,15 +304,19 @@ type OfflineQueryRequest struct {
} }
type AlertConfigUpdate struct { type AlertConfigUpdate struct {
ID uint `json:"id"` ID uint `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Title string `json:"title"` Title string `json:"title"`
Status string `json:"status"` Status string `json:"status"`
Config string `json:"config"` Config string `json:"config"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Revision *time.Time `json:"revision"`
} }
type AlertConfigTest struct { type AlertConfigTest struct {
ID uint `json:"id"`
Type string `json:"type"`
Config string `json:"config"`
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
Sender string `json:"sender"` Sender string `json:"sender"`
+80
View File
@@ -0,0 +1,80 @@
package dto
const AlertCustomWebhookSchemaVersion = 1
type AlertConfigStatusUpdate struct {
ID uint `json:"id" validate:"required"`
Status string `json:"status" validate:"required,oneof=Enable Disable"`
}
type AlertCustomWebhookSecretMutation struct {
Action string `json:"action,omitempty"`
Value string `json:"value,omitempty"`
}
type AlertCustomWebhookURL struct {
AlertCustomWebhookSecretMutation
Configured bool `json:"configured"`
Masked string `json:"masked,omitempty"`
}
type AlertCustomWebhookBody struct {
Type string `json:"type"`
Template string `json:"template,omitempty"`
Fields []AlertCustomWebhookFormField `json:"fields,omitempty"`
}
type AlertCustomWebhookFormField struct {
Key string `json:"key"`
Value string `json:"value"`
}
type AlertCustomWebhookHeader struct {
UID string `json:"uid"`
Key string `json:"key"`
Secret bool `json:"secret"`
Action string `json:"action,omitempty"`
Value string `json:"value,omitempty"`
Configured bool `json:"configured,omitempty"`
Masked string `json:"masked,omitempty"`
}
type AlertCustomWebhookConfig struct {
SchemaVersion int `json:"schemaVersion"`
State string `json:"state,omitempty"`
DisplayName string `json:"displayName"`
Preset string `json:"preset"`
Method string `json:"method"`
URL AlertCustomWebhookURL `json:"url"`
Body AlertCustomWebhookBody `json:"body"`
Headers []AlertCustomWebhookHeader `json:"headers"`
}
type AlertCustomWebhookSecretConfig struct {
SchemaVersion int `json:"schemaVersion"`
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
}
type AlertCustomWebhookResolvedConfig struct {
SchemaVersion int
DisplayName string
Preset string
Method string
URL string
Body AlertCustomWebhookBody
Headers []AlertCustomWebhookResolvedHeader
}
type AlertCustomWebhookResolvedHeader struct {
Key string
Value string
}
type AlertConfigTestResult struct {
Success bool `json:"success"`
StatusCode int `json:"statusCode,omitempty"`
Duration int64 `json:"duration,omitempty"` // milliseconds
Message string `json:"message,omitempty"`
Response string `json:"response,omitempty"`
}
+3
View File
@@ -2,6 +2,7 @@ package dto
import ( import (
"context" "context"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/app/task"
) )
@@ -132,6 +133,8 @@ type Locale struct {
Ko string `json:"ko"` Ko string `json:"ko"`
Tr string `json:"tr"` Tr string `json:"tr"`
Es string `json:"es-es" yaml:"es-es"` Es string `json:"es-es" yaml:"es-es"`
Fa string `json:"fa"`
Lo string `json:"lo"`
} }
type AppForm struct { type AppForm struct {
+9 -8
View File
@@ -65,14 +65,15 @@ type UploadForRecover struct {
} }
type CommonBackup struct { type CommonBackup struct {
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mongodb mysql-cluster postgresql-cluster redis-cluster container compose"` Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mongodb mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"` Name string `json:"name"`
DetailName string `json:"detailName"` DetailName string `json:"detailName"`
Secret string `json:"secret"` Secret string `json:"secret"`
StopBefore bool `json:"stopBefore"` IsImmediate bool `json:"isImmediate"`
TaskID string `json:"taskID"` StopBefore bool `json:"stopBefore"`
FileName string `json:"fileName"` TaskID string `json:"taskID"`
Args []string `json:"args"` FileName string `json:"fileName"`
Args []string `json:"args"`
Description string `json:"description"` Description string `json:"description"`
} }
+2 -1
View File
@@ -2,7 +2,8 @@ package dto
type SearchWithPage struct { type SearchWithPage struct {
PageInfo PageInfo
Info string `json:"info"` Info string `json:"info"`
ExcludeAppStore bool `json:"excludeAppStore"`
} }
type SearchPageWithType struct { type SearchPageWithType struct {
+4 -3
View File
@@ -6,9 +6,10 @@ type PageResult struct {
} }
type Response struct { type Response struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` ErrorCode string `json:"errorCode,omitempty"`
Data interface{} `json:"data"` Message string `json:"message"`
Data interface{} `json:"data"`
} }
type Options struct { type Options struct {
+16 -5
View File
@@ -134,10 +134,15 @@ type ExtraHost struct {
IP string `json:"ip"` IP string `json:"ip"`
} }
type ContainerNetwork struct { type ContainerNetwork struct {
Network string `json:"network"` Network string `json:"network"`
Ipv4 string `json:"ipv4"` Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"` Ipv6 string `json:"ipv6"`
MacAddr string `json:"macAddr"` MacAddr string `json:"macAddr"`
Links []string `json:"links"`
Aliases []string `json:"aliases"`
DriverOpts map[string]string `json:"driverOpts"`
GwPriority int `json:"gwPriority"`
LinkLocalIPs []string `json:"linkLocalIPs"`
} }
type ContainerCreateByCommand struct { type ContainerCreateByCommand struct {
@@ -295,6 +300,7 @@ type ComposeInfo struct {
ConfigFile string `json:"configFile"` ConfigFile string `json:"configFile"`
Workdir string `json:"workdir"` Workdir string `json:"workdir"`
ComposeFileExists bool `json:"composeFileExists"` ComposeFileExists bool `json:"composeFileExists"`
IsPinned bool `json:"isPinned"`
Path string `json:"path"` Path string `json:"path"`
Containers []ComposeContainer `json:"containers"` Containers []ComposeContainer `json:"containers"`
Env string `json:"env"` Env string `json:"env"`
@@ -309,6 +315,7 @@ type ComposeContainer struct {
type ComposeCreate struct { type ComposeCreate struct {
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
Name string `json:"name"` Name string `json:"name"`
DirName string `json:"dirName"`
From string `json:"from" validate:"required,oneof=edit path template"` From string `json:"from" validate:"required,oneof=edit path template"`
File string `json:"file"` File string `json:"file"`
Path string `json:"path"` Path string `json:"path"`
@@ -319,7 +326,7 @@ type ComposeCreate struct {
type ComposeOperation struct { type ComposeOperation struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Path string `json:"path"` Path string `json:"path"`
Operation string `json:"operation" validate:"required,oneof=up start restart stop down delete"` Operation string `json:"operation" validate:"required,oneof=up start restart stop down delete rebuild"`
WithFile bool `json:"withFile"` WithFile bool `json:"withFile"`
Force bool `json:"force"` Force bool `json:"force"`
} }
@@ -332,6 +339,10 @@ type ComposeUpdate struct {
Env string `json:"env"` Env string `json:"env"`
ForcePull bool `json:"forcePull"` ForcePull bool `json:"forcePull"`
} }
type ComposePin struct {
Name string `json:"name" validate:"required"`
IsPinned bool `json:"isPinned"`
}
type ComposeLogClean struct { type ComposeLogClean struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Path string `json:"path" validate:"required"` Path string `json:"path" validate:"required"`
+1
View File
@@ -197,6 +197,7 @@ type SearchRecord struct {
type Record struct { type Record struct {
ID uint `json:"id"` ID uint `json:"id"`
CronjobID uint `json:"cronjobID"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
StartTime string `json:"startTime"` StartTime string `json:"startTime"`
Records string `json:"records"` Records string `json:"records"`
+45 -9
View File
@@ -79,8 +79,9 @@ type NodeCurrent struct {
} }
type DashboardCurrent struct { type DashboardCurrent struct {
Uptime uint64 `json:"uptime"` Uptime uint64 `json:"uptime"`
TimeSinceUptime string `json:"timeSinceUptime"` TimeSinceUptime string `json:"timeSinceUptime"`
RunningTime RunningTime `json:"runningTime"`
Procs uint64 `json:"procs"` Procs uint64 `json:"procs"`
@@ -120,6 +121,7 @@ type DashboardCurrent struct {
NetBytesRecv uint64 `json:"netBytesRecv"` NetBytesRecv uint64 `json:"netBytesRecv"`
GPUData []GPUInfo `json:"gpuData"` GPUData []GPUInfo `json:"gpuData"`
NPUData []NPUInfo `json:"npuData"`
XPUData []XPUInfo `json:"xpuData"` XPUData []XPUInfo `json:"xpuData"`
TopCPUItems []Process `json:"topCPUItems"` TopCPUItems []Process `json:"topCPUItems"`
@@ -128,6 +130,13 @@ type DashboardCurrent struct {
ShotTime time.Time `json:"shotTime"` ShotTime time.Time `json:"shotTime"`
} }
type RunningTime struct {
Days uint64 `json:"days"`
Hours uint64 `json:"hours"`
Minutes uint64 `json:"minutes"`
Seconds uint64 `json:"seconds"`
}
type AppLauncherSync struct { type AppLauncherSync struct {
Keys []string `json:"keys"` Keys []string `json:"keys"`
} }
@@ -148,8 +157,12 @@ type DiskInfo struct {
} }
type GPUInfo struct { type GPUInfo struct {
Type string `json:"type"`
Index uint `json:"index"` Index uint `json:"index"`
NPUIndex uint `json:"npuIndex"`
ChipIndex uint `json:"chipIndex"`
ProductName string `json:"productName"` ProductName string `json:"productName"`
BusID string `json:"busID"`
GPUUtil string `json:"gpuUtil"` GPUUtil string `json:"gpuUtil"`
Temperature string `json:"temperature"` Temperature string `json:"temperature"`
PerformanceState string `json:"performanceState"` PerformanceState string `json:"performanceState"`
@@ -162,6 +175,27 @@ type GPUInfo struct {
FanSpeed string `json:"fanSpeed"` FanSpeed string `json:"fanSpeed"`
} }
type NPUInfo struct {
Type string `json:"type"`
Index uint `json:"index"`
NPUIndex uint `json:"npuIndex"`
ChipIndex uint `json:"chipIndex"`
ProductName string `json:"productName"`
BusID string `json:"busID"`
Health string `json:"health"`
Temperature string `json:"temperature"`
PowerDraw string `json:"powerDraw"`
AICore string `json:"aiCore"`
MemUsed string `json:"memUsed"`
MemTotal string `json:"memTotal"`
MemoryUsed string `json:"memoryUsed"`
MemoryTotal string `json:"memoryTotal"`
HBMUsed string `json:"hbmUsed"`
HBMTotal string `json:"hbmTotal"`
HugepagesUsed string `json:"hugepagesUsed"`
HugepagesTotal string `json:"hugepagesTotal"`
}
type AppLauncher struct { type AppLauncher struct {
Key string `json:"key"` Key string `json:"key"`
Type string `json:"type"` Type string `json:"type"`
@@ -194,11 +228,13 @@ type LauncherOption struct {
} }
type XPUInfo struct { type XPUInfo struct {
DeviceID int `json:"deviceID"` DeviceID int `json:"deviceID"`
DeviceName string `json:"deviceName"` DeviceName string `json:"deviceName"`
Memory string `json:"memory"` PciBdfAddress string `json:"pciBdfAddress"`
Temperature string `json:"temperature"` Memory string `json:"memory"`
MemoryUsed string `json:"memoryUsed"` Temperature string `json:"temperature"`
Power string `json:"power"` GPUUtil string `json:"gpuUtil"`
MemoryUtil string `json:"memoryUtil"` MemoryUsed string `json:"memoryUsed"`
Power string `json:"power"`
MemoryUtil string `json:"memoryUtil"`
} }
+69 -10
View File
@@ -60,25 +60,84 @@ type MysqlDBCreate struct {
Database string `json:"database" validate:"required"` Database string `json:"database" validate:"required"`
Format string `json:"format" validate:"required"` Format string `json:"format" validate:"required"`
Collation string `json:"collation"` Collation string `json:"collation"`
Username string `json:"username" validate:"required"` Username string `json:"username"`
Password string `json:"password" validate:"required"` Password string `json:"password"`
Permission string `json:"permission" validate:"required"` Permission string `json:"permission" validate:"required"`
Description string `json:"description"` Description string `json:"description"`
} }
type MysqlUser struct {
Username string `json:"username"`
Host string `json:"host"`
Password string `json:"password"`
Description string `json:"description"`
IsDelete bool `json:"isDelete"`
}
type MysqlGrant struct {
Database string `json:"database"`
Username string `json:"username"`
Host string `json:"host"`
}
type MysqlGrantSummarySearch struct {
Database string `json:"database" validate:"required"`
DBs []string `json:"dbs" validate:"required"`
}
type MysqlUserSearch struct {
Database string `json:"database" validate:"required"`
}
type MysqlUserCreate struct {
Database string `json:"database" validate:"required"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Host string `json:"host" validate:"required"`
Description string `json:"description"`
DBs []string `json:"dbs"`
}
type MysqlUserDelete struct {
Database string `json:"database" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
}
type MysqlUserUpdate struct {
Database string `json:"database" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
NewHost string `json:"newHost" validate:"required"`
Description string `json:"description"`
}
type MysqlUserPassword struct {
Database string `json:"database" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
Password string `json:"password" validate:"required"`
}
type MysqlGrantCreate struct {
Database string `json:"database" validate:"required"`
DB string `json:"db" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
}
type MysqlGrantDelete struct {
Database string `json:"database" validate:"required"`
DB string `json:"db" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
}
type MysqlFormatCollationOption struct { type MysqlFormatCollationOption struct {
Format string `json:"format"` Format string `json:"format"`
Collations []string `json:"collations"` Collations []string `json:"collations"`
} }
type BindUser struct {
Database string `json:"database" validate:"required"`
DB string `json:"db" validate:"required"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Permission string `json:"permission" validate:"required"`
}
type MysqlLoadDB struct { type MysqlLoadDB struct {
From string `json:"from" validate:"required,oneof=local remote"` From string `json:"from" validate:"required,oneof=local remote"`
Type string `json:"type" validate:"required,oneof=mysql mariadb mysql-cluster"` Type string `json:"type" validate:"required,oneof=mysql mariadb mysql-cluster"`
+356 -84
View File
@@ -1,113 +1,385 @@
package dto package dto
type FirewallBaseInfo struct { import (
Name string `json:"name"` "github.com/1Panel-dev/1Panel/agent/utils/firewall"
IsExist bool `json:"isExist"` "github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
IsActive bool `json:"isActive"` firewallsync "github.com/1Panel-dev/1Panel/agent/utils/firewall/sync"
IsInit bool `json:"isInit"` )
IsBind bool `json:"isBind"`
Version string `json:"version"` type FirewallSubsystemStatus struct {
PingStatus string `json:"pingStatus"` Name string `json:"name"`
Backend string `json:"backend"`
ConflictBackend string `json:"conflictBackend,omitempty"`
IsExist bool `json:"isExist"`
IsActive bool `json:"isActive"`
IsInit bool `json:"isInit"`
IsBind bool `json:"isBind"`
Version string `json:"version"`
PingStatus string `json:"pingStatus"`
Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"`
SyncError string `json:"syncError,omitempty"`
LifecycleTaskID string `json:"lifecycleTaskID,omitempty"`
IPv4 FirewallBackendFamilyStatus `json:"ipv4"`
IPv6 FirewallBackendFamilyStatus `json:"ipv6"`
} }
type RuleSearch struct { type FirewallLifecycleOperation struct {
PageInfo
Info string `json:"info"`
Status string `json:"status"`
Strategy string `json:"strategy"`
Type string `json:"type" validate:"required"`
}
type FirewallOperation struct {
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"`
} }
type PortRuleOperate struct { type FirewallLifecycleOperationResponse struct {
ID uint `json:"id"` TaskID string `json:"taskID,omitempty"`
Operation string `json:"operation" validate:"required,oneof=add remove"` Queued bool `json:"queued"`
Chain string `json:"chain"`
Address string `json:"address"`
Port string `json:"port" validate:"required"`
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"`
Strategy string `json:"strategy" validate:"required,oneof=accept drop"`
Description string `json:"description"`
} }
type ForwardRuleOperate struct { type FirewallBackendOption struct {
ForceDelete bool `json:"forceDelete"` Name string `json:"name"`
Rules []struct { Installed bool `json:"installed"`
Operation string `json:"operation" validate:"required,oneof=add remove"` Active bool `json:"active"`
Num string `json:"num"` Initialized bool `json:"initialized"`
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"` Bound bool `json:"bound"`
Interface string `json:"interface"` Supported bool `json:"supported"`
Port string `json:"port" validate:"required"` SupportReason string `json:"supportReason,omitempty"`
TargetIP string `json:"targetIP"` Implementation string `json:"implementation,omitempty"`
TargetPort string `json:"targetPort" validate:"required"` Message string `json:"message,omitempty"`
} `json:"rules"` IPv4 FirewallBackendFamilyStatus `json:"ipv4"`
IPv6 FirewallBackendFamilyStatus `json:"ipv6"`
} }
type UpdateFirewallDescription struct { type FirewallBackendFamilyStatus struct {
Type string `json:"type"` Available bool `json:"available"`
Chain string `json:"chain"` Initialized bool `json:"initialized"`
SrcIP string `json:"srcIP"` Bound bool `json:"bound"`
DstIP string `json:"dstIP"` Reason string `json:"reason,omitempty"`
SrcPort string `json:"srcPort"`
DstPort string `json:"dstPort"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy" validate:"required,oneof=accept drop"`
Description string `json:"description"`
} }
type AddrRuleOperate struct { type FirewallBackendGroup struct {
ID uint `json:"id"` Selected string `json:"selected"`
Operation string `json:"operation" validate:"required,oneof=add remove"` Current string `json:"current,omitempty"`
Address string `json:"address" validate:"required"` Options []FirewallBackendOption `json:"options"`
Strategy string `json:"strategy" validate:"required,oneof=accept drop"`
Description string `json:"description"`
} }
type PortRuleUpdate struct { type FirewallSettings struct {
OldRule PortRuleOperate `json:"oldRule"` System FirewallBackendGroup `json:"system"`
NewRule PortRuleOperate `json:"newRule"` Forwarding FirewallBackendGroup `json:"forwarding"`
Docker FirewallBackendGroup `json:"docker"`
PingStatus string `json:"pingStatus"`
PortWhitelist []filter.PortWhitelist `json:"portWhiteList"`
PanelPort string `json:"panelPort"`
SSHPort string `json:"sshPort"`
} }
type AddrRuleUpdate struct { type FirewallPortWhitelistCreate struct {
OldRule AddrRuleOperate `json:"oldRule"` Rule filter.PortWhitelist `json:"rule" validate:"required"`
NewRule AddrRuleOperate `json:"newRule"`
} }
type BatchRuleOperate struct { type FirewallPortWhitelistUpdate struct {
Type string `json:"type" validate:"required"` OldRule filter.PortWhitelist `json:"oldRule" validate:"required"`
Rules []PortRuleOperate `json:"rules"` Rule filter.PortWhitelist `json:"rule" validate:"required"`
} }
type IptablesOp struct { type FirewallPortWhitelistDelete struct {
Name string `json:"name" validate:"required,oneof=1PANEL_INPUT 1PANEL_OUTPUT 1PANEL_BASIC"` Rule *filter.PortWhitelist `json:"rule" validate:"required"`
Operate string `json:"operate" validate:"required,oneof=init-base init-forward init-advance bind-base unbind-base bind unbind"`
} }
type IptablesRuleOp struct { type FirewallBackendOperation struct {
Operation string `json:"operation" validate:"required,oneof=add remove"` Subsystem string `json:"subsystem" validate:"required,oneof=system forwarding docker"`
ID uint `json:"id"` Backend string `json:"backend" validate:"required,oneof=firewalld ufw iptables nftables"`
Chain string `json:"chain" validate:"required,oneof=1PANEL_BASIC 1PANEL_BASIC_BEFORE 1PANEL_INPUT 1PANEL_OUTPUT"` Operation string `json:"operation" validate:"required,oneof=select initialize cleanup"`
Protocol string `json:"protocol"`
SrcIP string `json:"srcIP"`
SrcPort uint `json:"srcPort"`
DstIP string `json:"dstIP"`
DstPort uint `json:"dstPort"`
Strategy string `json:"strategy" validate:"required,oneof=accept drop reject"`
Description string `json:"description"`
} }
type IptablesBatchOperate struct { type FilterChainOperation struct {
Rules []IptablesRuleOp `json:"rules"` Name string `json:"name" validate:"required,eq=1PANEL_BASIC"`
Operate string `json:"operate" validate:"required,oneof=init-base bind-base unbind-base"`
TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"`
} }
type IptablesChainStatus struct { type FilterChainOperationResponse struct {
IsBind bool `json:"isBind"` TaskID string `json:"taskID"`
DefaultStrategy string `json:"defaultStrategy"` Queued bool `json:"queued"`
}
type FirewallInitializationTask struct {
TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"`
}
type FirewallSystemPort = firewall.SystemPort
type FirewallRuleInventoryResponse struct {
IPv4Range filter.PositionRange `json:"ipv4Range"`
IPv6Range filter.PositionRange `json:"ipv6Range"`
Total int64 `json:"total"`
AllTotal int64 `json:"allTotal"`
ManagedTotal int64 `json:"managedTotal"`
Items []filter.InventoryItem `json:"items"`
Notices []filter.ScopeNotice `json:"notices,omitempty"`
}
type FirewallRuleResetResponse struct {
Removed int `json:"removed"`
Disabled bool `json:"disabled"`
}
type FirewallRuleReset struct {
Provider filter.Provider `json:"provider,omitempty" validate:"omitempty,oneof=firewalld ufw iptables nftables"`
WithDockerRestart bool `json:"withDockerRestart"`
}
type FirewallRuleInventory struct {
Refresh bool `json:"refresh,omitempty"`
PageInfo
Scope filter.Scope `json:"scope,omitempty"`
Scopes []filter.Scope `json:"scopes,omitempty" validate:"max=16"`
All bool `json:"all,omitempty"`
Info string `json:"info"`
Families []filter.Family `json:"families,omitempty" validate:"omitempty,dive,oneof=ipv4 ipv6"`
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"`
}
type FirewallNativeDetail struct {
Provider filter.Provider `json:"provider" validate:"required,oneof=firewalld ufw"`
NativeKind filter.NativeKind `json:"nativeKind" validate:"required,oneof=zone_service ufw_application"`
Name string `json:"name" validate:"required"`
Permanent bool `json:"permanent"`
}
type DockerPortGuardBase struct {
Name string `json:"name"`
Version string `json:"version"`
IsExist bool `json:"isExist"`
Initialized bool `json:"initialized"`
Bound bool `json:"bound"`
IPv4 DockerPortGuardFamilyStatus `json:"ipv4"`
IPv6 DockerPortGuardFamilyStatus `json:"ipv6"`
Backend string `json:"backend"`
Message string `json:"message,omitempty"`
}
type DockerPortGuardFamilyStatus struct {
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Initialized bool `json:"initialized"`
Bound bool `json:"bound"`
Effective bool `json:"effective"`
}
type DockerPortGuardEndpoint struct {
Family string `json:"family"`
HostIP string `json:"hostIP"`
HostPort uint16 `json:"hostPort"`
Protocol string `json:"protocol"`
ContainerID string `json:"containerID"`
ContainerName string `json:"containerName"`
ContainerState string `json:"containerState,omitempty"`
ContainerPort uint16 `json:"containerPort"`
Compose string `json:"compose,omitempty"`
Application string `json:"application,omitempty"`
PolicyUUID string `json:"policyUUID,omitempty"`
Mode string `json:"mode,omitempty"`
NativeAction string `json:"nativeAction,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Sources []string `json:"sources"`
Effective bool `json:"effective"`
Description string `json:"description,omitempty"`
TrafficPath string `json:"trafficPath"`
ManagementTarget string `json:"managementTarget"`
ManagementReason string `json:"managementReason,omitempty"`
}
type DockerPortGuardPortGroup struct {
Key string `json:"key"`
Label string `json:"label"`
Endpoint DockerPortGuardEndpoint `json:"endpoint"`
Endpoints []DockerPortGuardEndpoint `json:"endpoints"`
}
type DockerPortGuardContainer struct {
Key string `json:"key"`
Name string `json:"name"`
Compose string `json:"compose,omitempty"`
Application string `json:"application,omitempty"`
Endpoints []DockerPortGuardEndpoint `json:"endpoints"`
PortGroups []DockerPortGuardPortGroup `json:"portGroups"`
}
type DockerPortGuardList struct {
Base DockerPortGuardBase `json:"base"`
Containers []DockerPortGuardContainer `json:"containers"`
OrphanPolicies []DockerPortGuardEndpoint `json:"orphanPolicies"`
}
type DockerPortGuardEndpointIdentity struct {
Family string `json:"family" validate:"required,oneof=ipv4 ipv6"`
HostIP string `json:"hostIP" validate:"required,max=45"`
HostPort uint16 `json:"hostPort" validate:"required,min=1"`
Protocol string `json:"protocol" validate:"required,oneof=tcp udp"`
}
type DockerPortGuardPolicyBatch struct {
Policies []DockerPortGuardPolicy `json:"policies" validate:"required,min=1,dive"`
}
type DockerPortGuardPolicyBatchDelete struct {
UUIDs []string `json:"uuids" validate:"required,min=1,dive,required,max=64"`
}
type DockerPortGuardPolicy struct {
DockerPortGuardEndpointIdentity
Mode string `json:"mode" validate:"required,oneof=deny_sources allow_sources deny_all"`
Sources []string `json:"sources" validate:"dive,required,max=64"`
Description string `json:"description" validate:"max=256"`
}
type DockerPortGuardOperation struct {
Operation string `json:"operation" validate:"required,oneof=initialize bind unbind"`
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 {
Rule filter.FirewallRule `json:"rule" validate:"required"`
SourceKind string `json:"sourceKind" validate:"omitempty,oneof=user imported"`
SourceID string `json:"sourceID"`
}
type FirewallRuleCreate struct {
Items []FirewallRuleCreateItem `json:"items" validate:"required,min=1,dive"`
}
type FirewallRuleCreateResponse struct {
TaskID string `json:"taskID,omitempty"`
Queued bool `json:"queued,omitempty"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Errors []FirewallRuleCreateFailure `json:"errors,omitempty"`
}
type FirewallRuleCreateFailure struct {
Index int `json:"index"`
Status string `json:"status"`
Rule filter.FirewallRule `json:"rule"`
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 {
UUIDs []string `json:"uuids" validate:"omitempty,dive,required,max=64"`
BeforeRules []FirewallRuleDeleteTarget `json:"beforeRules,omitempty" validate:"omitempty,dive"`
}
type FirewallRuleDeleteTarget struct {
Scope filter.Scope `json:"scope" validate:"required"`
InstanceKey string `json:"instanceKey" validate:"required,max=128"`
}
type FirewallRuleDeleteResponse struct {
TaskID string `json:"taskID,omitempty"`
Queued bool `json:"queued,omitempty"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Errors []FirewallRuleDeleteFailure `json:"errors,omitempty"`
}
type FirewallRuleDeleteFailure struct {
Index int `json:"index"`
UUID string `json:"uuid"`
Error string `json:"error"`
}
type FirewallRuleUpdate struct {
UUID string `json:"uuid" validate:"required,max=64"`
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"`
OrderIndex *int64 `json:"orderIndex,omitempty" validate:"excluded_with=Rule Priority"`
Priority *int `json:"priority,omitempty" validate:"excluded_with=Rule OrderIndex"`
}
type FirewallRuleReorder struct {
UUID string `json:"uuid" validate:"required,max=64"`
TargetPosition *int64 `json:"targetPosition"`
Priority *int `json:"priority"`
}
func (p *FirewallRuleSyncPreview) Add(item FirewallRuleSyncItem) {
p.Items = append(p.Items, item)
switch item.Status {
case firewallsync.StatusReady:
p.Ready++
p.Total++
case firewallsync.StatusExisting:
p.Existing++
p.Total++
case firewallsync.StatusRemove:
p.Removed++
case firewallsync.StatusBlocked:
p.Blocked++
if item.ReasonCode != firewallsync.ReasonReadOnlyRule {
p.Total++
}
}
} }
+47
View File
@@ -0,0 +1,47 @@
package dto
type ForwardRuleSearch struct {
PageInfo
All bool `json:"all,omitempty"`
Info string `json:"info"`
Status string `json:"status"`
Strategy string `json:"strategy"`
}
type ForwardRule struct {
ID uint `json:"id"`
Chain string `json:"chain"`
Family string `json:"family"`
Address string `json:"address"`
Port string `json:"port"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Num string `json:"num"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort"`
Interface string `json:"interface"`
UsedStatus string `json:"usedStatus"`
Description string `json:"description"`
IsDesired bool `json:"isDesired"`
IsRuntime bool `json:"isRuntime"`
SyncStatus string `json:"syncStatus"`
}
type ForwardRuleOperate struct {
ForceDelete bool `json:"forceDelete"`
Rules []ForwardRuleOperation `json:"rules" validate:"required,min=1,dive"`
}
type ForwardRuleOperation struct {
Operation string `json:"operation" validate:"required,oneof=add remove"`
Num string `json:"num"`
Family string `json:"family" validate:"omitempty,oneof=ipv4 ipv6"`
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"`
Interface string `json:"interface"`
Port string `json:"port" validate:"required"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort" validate:"required"`
}
+36
View File
@@ -1,6 +1,8 @@
package dto package dto
import ( import (
"time"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
) )
@@ -14,3 +16,37 @@ type SearchTaskLogReq struct {
type TaskDTO struct { type TaskDTO struct {
model.Task model.Task
} }
type SystemLogReq struct {
PageSize int `json:"pageSize" validate:"omitempty,min=1,max=500"`
Cursor string `json:"cursor"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Keyword string `json:"keyword"`
Priority string `json:"priority"`
Service string `json:"service"`
}
type SystemLogRes struct {
Source string `json:"source"`
Items []SystemLogItem `json:"items"`
HasMore bool `json:"hasMore"`
NextCursor string `json:"nextCursor"`
}
type SystemLogStatus struct {
Source string `json:"source"`
Version string `json:"version"`
KeywordFilterSupported bool `json:"keywordFilterSupported"`
Message string `json:"message"`
}
type SystemLogItem struct {
Timestamp int64 `json:"-"`
Cursor string `json:"-"`
Time string `json:"time"`
Priority string `json:"priority"`
Service string `json:"service"`
Message string `json:"message"`
Raw string `json:"raw"`
}
+2
View File
@@ -45,10 +45,12 @@ type MonitorGPUOptions struct {
} }
type GPUChartHide struct { type GPUChartHide struct {
ProductName string `json:"productName"` ProductName string `json:"productName"`
Type string `json:"type"`
Process bool `json:"process"` Process bool `json:"process"`
GPU bool `json:"gpu"` GPU bool `json:"gpu"`
Memory bool `json:"memory"` Memory bool `json:"memory"`
Power bool `json:"power"` Power bool `json:"power"`
PowerLimit bool `json:"powerLimit"`
Temperature bool `json:"temperature"` Temperature bool `json:"temperature"`
Speed bool `json:"speed"` Speed bool `json:"speed"`
} }
+46 -6
View File
@@ -1,6 +1,8 @@
package dto package dto
import ( import (
"time"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/utils/nginx/components" "github.com/1Panel-dev/1Panel/agent/utils/nginx/components"
) )
@@ -49,13 +51,19 @@ const (
CACHE NginxKey = "cache" CACHE NginxKey = "cache"
HttpPer NginxKey = "http-per" HttpPer NginxKey = "http-per"
ProxyCache NginxKey = "proxy-cache" ProxyCache NginxKey = "proxy-cache"
Brotli NginxKey = "brotli"
) )
// BrotliKeys are served from the panel-managed http.d file rather than
// nginx.conf, because the module is optional: its directives must disappear
// together with the module, otherwise nginx refuses to start.
var BrotliKeys = []string{"brotli", "brotli_comp_level", "brotli_min_length", "brotli_types"}
var ScopeKeyMap = map[NginxKey][]string{ var ScopeKeyMap = map[NginxKey][]string{
Index: {"index"}, Index: {"index"},
LimitConn: {"limit_conn", "limit_rate", "limit_conn_zone"}, LimitConn: {"limit_conn", "limit_rate", "limit_conn_zone"},
SSL: {"ssl_certificate", "ssl_certificate_key"}, SSL: {"ssl_certificate", "ssl_certificate_key"},
HttpPer: {"server_names_hash_bucket_size", "client_header_buffer_size", "client_max_body_size", "keepalive_timeout", "gzip", "gzip_min_length", "gzip_comp_level"}, HttpPer: {"server_names_hash_bucket_size", "client_header_buffer_size", "client_max_body_size", "keepalive_timeout", "gzip", "gzip_min_length", "gzip_comp_level", "gzip_types", "gzip_vary", "gzip_proxied"},
} }
var StaticFileKeyMap = map[NginxKey]struct { var StaticFileKeyMap = map[NginxKey]struct {
@@ -87,9 +95,41 @@ var LBAlgorithms = map[string]struct{}{"ip_hash": {}, "least_conn": {}}
var RealIPKeys = map[string]struct{}{"X-Forwarded-For": {}, "X-Real-IP": {}, "CF-Connecting-IP": {}} var RealIPKeys = map[string]struct{}{"X-Forwarded-For": {}, "X-Real-IP": {}, "CF-Connecting-IP": {}}
type NginxModule struct { type NginxModule struct {
Name string `json:"name"` Name string `json:"name"`
Script string `json:"script"` Custom bool `json:"custom,omitempty"`
Packages []string `json:"packages"` Script string `json:"script"`
Params string `json:"params"` Packages []string `json:"packages"`
Enable bool `json:"enable"` Params string `json:"params"`
Enable bool `json:"enable"`
BuildMode string `json:"buildMode,omitempty"`
Provider string `json:"provider,omitempty"`
LoadOrder int `json:"loadOrder,omitempty"`
Builds []NginxModuleBuild `json:"builds,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type NginxModuleBuild struct {
Provider string `json:"provider"`
BuildMode string `json:"buildMode"`
Status string `json:"status"`
Hash string `json:"hash"`
Target NginxModuleTarget `json:"target"`
Artifacts []NginxModuleArtifact `json:"artifacts,omitempty"`
Error string `json:"error,omitempty"`
BuiltAt time.Time `json:"builtAt,omitempty"`
}
type NginxModuleTarget struct {
Key string `json:"key"`
OpenRestyVersion string `json:"openrestyVersion"`
Architecture string `json:"architecture"`
Image string `json:"image,omitempty"`
ImageDigest string `json:"imageDigest,omitempty"`
BuilderDigest string `json:"builderDigest,omitempty"`
}
type NginxModuleArtifact struct {
Name string `json:"name"`
Path string `json:"path"`
Checksum string `json:"checksum"`
} }
+10
View File
@@ -50,6 +50,10 @@ type AppContainerConfig struct {
Type string `json:"type"` Type string `json:"type"`
SpecifyIP string `json:"specifyIP"` SpecifyIP string `json:"specifyIP"`
RestartPolicy string `json:"restartPolicy" validate:"omitempty,oneof=always unless-stopped no on-failure"` RestartPolicy string `json:"restartPolicy" validate:"omitempty,oneof=always unless-stopped no on-failure"`
KeepServiceName bool `json:"-"`
SkipComposeCommonConfig bool `json:"-"`
UseLifecycleScripts bool `json:"-"`
} }
type AppInstalledSearch struct { type AppInstalledSearch struct {
@@ -92,6 +96,8 @@ type AppInstalledOperate struct {
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
DeleteImage bool `json:"deleteImage"` DeleteImage bool `json:"deleteImage"`
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`
UseLifecycleScripts bool `json:"-"`
} }
type AppInstallUpgrade struct { type AppInstallUpgrade struct {
@@ -99,6 +105,7 @@ type AppInstallUpgrade struct {
DetailID uint `json:"detailId"` DetailID uint `json:"detailId"`
Backup bool `json:"backup"` Backup bool `json:"backup"`
PullImage bool `json:"pullImage"` PullImage bool `json:"pullImage"`
DeleteImage bool `json:"deleteImage"`
DockerCompose string `json:"dockerCompose"` DockerCompose string `json:"dockerCompose"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
} }
@@ -110,11 +117,14 @@ type AppInstallDelete struct {
DeleteDB bool `json:"deleteDB"` DeleteDB bool `json:"deleteDB"`
DeleteImage bool `json:"deleteImage"` DeleteImage bool `json:"deleteImage"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
UseLifecycleScripts bool `json:"-"`
} }
type AppInstalledUpdate struct { type AppInstalledUpdate struct {
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:"-"`
AppContainerConfig AppContainerConfig
} }
+10
View File
@@ -122,6 +122,7 @@ type FileWget struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
IgnoreCertificate bool `json:"ignoreCertificate"` IgnoreCertificate bool `json:"ignoreCertificate"`
UseProxy bool `json:"useProxy"` UseProxy bool `json:"useProxy"`
UseServerFilename bool `json:"useServerFilename"`
} }
type FileMove struct { type FileMove struct {
@@ -131,6 +132,11 @@ type FileMove struct {
Name string `json:"name"` Name string `json:"name"`
Cover bool `json:"cover"` Cover bool `json:"cover"`
CoverPaths []string `json:"coverPaths"` CoverPaths []string `json:"coverPaths"`
TaskID string `json:"taskID"`
}
type FileMoveStopReq struct {
TaskID string `json:"taskID" validate:"required"`
} }
type FileDownload struct { type FileDownload struct {
@@ -153,6 +159,10 @@ type FileProcessReq struct {
Key string `json:"key"` Key string `json:"key"`
} }
type FileProcessRemoveReq struct {
Keys []string `json:"keys" validate:"required,min=1,max=1000"`
}
type FileRoleUpdate struct { type FileRoleUpdate struct {
Path string `json:"path" validate:"required"` Path string `json:"path" validate:"required"`
User string `json:"user" validate:"required"` User string `json:"user" validate:"required"`
+16
View File
@@ -21,6 +21,10 @@ type McpServerCreate struct {
StreamableHttpPath string `json:"streamableHttpPath"` StreamableHttpPath string `json:"streamableHttpPath"`
OutputTransport string `json:"outputTransport" validate:"required"` OutputTransport string `json:"outputTransport" validate:"required"`
Type string `json:"type" validate:"required"` Type string `json:"type" validate:"required"`
GatewayImage string `json:"gatewayImage"`
ProtocolVersion string `json:"protocolVersion"`
GatewayArgs string `json:"gatewayArgs" validate:"max=4096"`
TaskID string `json:"taskID"`
} }
type McpServerUpdate struct { type McpServerUpdate struct {
@@ -32,11 +36,23 @@ type McpServerDelete struct {
ID uint `json:"id" validate:"required"` ID uint `json:"id" validate:"required"`
} }
type McpServerDetail struct {
ID uint `json:"id" validate:"required"`
}
type McpServerStatusSync struct {
IDs []uint `json:"ids"`
}
type McpServerOperate struct { type McpServerOperate struct {
ID uint `json:"id" validate:"required"` ID uint `json:"id" validate:"required"`
Operate string `json:"operate" validate:"required"` Operate string `json:"operate" validate:"required"`
} }
type McpServerConnectionTest struct {
ID uint `json:"id" validate:"required"`
}
type McpBindDomain struct { type McpBindDomain struct {
Domain string `json:"domain" validate:"required"` Domain string `json:"domain" validate:"required"`
SSLID uint `json:"sslID"` SSLID uint `json:"sslID"`
+13 -8
View File
@@ -114,17 +114,22 @@ type NginxRedirectUpdate struct {
} }
type NginxBuildReq struct { type NginxBuildReq struct {
TaskID string `json:"taskID" validate:"required"` TaskID string `json:"taskID" validate:"required"`
Mirror string `json:"mirror" validate:"required"` Mirror string `json:"mirror" validate:"required"`
Modules []string `json:"modules"`
Force bool `json:"force"`
} }
type NginxModuleUpdate struct { type NginxModuleUpdate struct {
Operate string `json:"operate" validate:"required,oneof=create delete update"` Operate string `json:"operate" validate:"required,oneof=create delete update"`
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Script string `json:"script"` Script string `json:"script"`
Packages string `json:"packages"` Packages string `json:"packages"`
Enable bool `json:"enable"` Enable bool `json:"enable"`
Params string `json:"params"` Params string `json:"params"`
BuildMode string `json:"buildMode" validate:"omitempty,oneof=dynamic static"`
Provider string `json:"provider" validate:"omitempty,oneof=local prebuilt"`
LoadOrder int `json:"loadOrder" validate:"omitempty,min=0,max=9999"`
} }
type NginxOperateReq struct { type NginxOperateReq struct {
+17 -11
View File
@@ -21,13 +21,14 @@ type RuntimeCreate struct {
Source string `json:"source"` Source string `json:"source"`
CodeDir string `json:"codeDir"` CodeDir string `json:"codeDir"`
Remark string `json:"remark"` Remark string `json:"remark"`
TaskID string `json:"taskID"`
Params map[string]interface{} `json:"params"` Params map[string]interface{} `json:"params"`
NodeConfig NodeConfig
} }
type NodeConfig struct { type NodeConfig struct {
Install bool `json:"install"` Install *bool `json:"install"`
Clean bool `json:"clean"` Clean bool `json:"clean"`
ExposedPorts []ExposedPort `json:"exposedPorts"` ExposedPorts []ExposedPort `json:"exposedPorts"`
Environments []Environment `json:"environments"` Environments []Environment `json:"environments"`
@@ -42,12 +43,14 @@ type Environment struct {
type Volume struct { type Volume struct {
Source string `json:"source"` Source string `json:"source"`
Target string `json:"target"` Target string `json:"target"`
Mode string `json:"mode"`
} }
type ExposedPort struct { type ExposedPort struct {
HostPort int `json:"hostPort"` HostPort int `json:"hostPort"`
ContainerPort int `json:"containerPort"` ContainerPort int `json:"containerPort"`
HostIP string `json:"hostIP"` HostIP string `json:"hostIP"`
Protocol string `json:"protocol"`
} }
type ExtraHost struct { type ExtraHost struct {
@@ -56,19 +59,22 @@ type ExtraHost struct {
} }
type RuntimeDelete struct { type RuntimeDelete struct {
ID uint `json:"id"` ID uint `json:"id"`
ForceDelete bool `json:"forceDelete"` ForceDelete bool `json:"forceDelete"`
DeleteImage bool `json:"deleteImage"`
TaskID string `json:"taskID"`
} }
type RuntimeUpdate struct { type RuntimeUpdate struct {
Name string `json:"name"` AppDetailID uint `json:"appDetailId"`
ID uint `json:"id"` Name string `json:"name"`
Image string `json:"image"` ID uint `json:"id"`
Version string `json:"version"` Image string `json:"image"`
Rebuild bool `json:"rebuild"` Version string `json:"version"`
Source string `json:"source"` Rebuild bool `json:"rebuild"`
CodeDir string `json:"codeDir"` Source string `json:"source"`
Remark string `json:"remark"` CodeDir string `json:"codeDir"`
Remark string `json:"remark"`
Params map[string]interface{} `json:"params"` Params map[string]interface{} `json:"params"`
NodeConfig NodeConfig
+2
View File
@@ -34,6 +34,8 @@ type WebsiteCreate struct {
SiteDir string `json:"siteDir"` SiteDir string `json:"siteDir"`
TemplateOutputID uint `json:"templateOutputID"`
RuntimeConfig RuntimeConfig
FtpConfig FtpConfig
DataBaseConfig DataBaseConfig
+14
View File
@@ -126,6 +126,16 @@ type WebsiteSSLUpload struct {
Type string `json:"type" validate:"required,oneof=paste local"` Type string `json:"type" validate:"required,oneof=paste local"`
SSLID uint `json:"sslID"` SSLID uint `json:"sslID"`
Description string `json:"description"` Description string `json:"description"`
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
}
type WebsiteSSLPush struct {
ID uint `json:"id" validate:"required"`
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
TaskID string `json:"taskID" validate:"required"`
Sync bool `json:"sync"`
} }
type WebsiteCASearch struct { type WebsiteCASearch struct {
@@ -157,6 +167,8 @@ type WebsiteCAObtain struct {
Description string `json:"description"` Description string `json:"description"`
ExecShell bool `json:"execShell"` ExecShell bool `json:"execShell"`
Shell string `json:"shell"` Shell string `json:"shell"`
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
} }
type WebsiteCARenew struct { type WebsiteCARenew struct {
@@ -167,4 +179,6 @@ type WebsiteSSLFileUpload struct {
Type string `json:"type"` Type string `json:"type"`
Description string `json:"description"` Description string `json:"description"`
SSLID uint64 `json:"sslID"` SSLID uint64 `json:"sslID"`
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
} }
+46
View File
@@ -0,0 +1,46 @@
package request
import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
)
type WebsiteTemplateSearch struct {
dto.PageInfo
Name string `json:"name"`
Type string `json:"type"`
}
type WebsiteTemplateCreate struct {
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required,oneof=single multi"`
Content string `json:"content"`
FilePath string `json:"filePath"`
Variables string `json:"variables"`
Remark string `json:"remark"`
}
type WebsiteTemplateUpdate struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required,oneof=single multi"`
Content string `json:"content"`
FilePath string `json:"filePath"`
Variables string `json:"variables"`
Remark string `json:"remark"`
}
type WebsiteTemplateOutputSearch struct {
dto.PageInfo
TemplateID uint `json:"templateID"`
}
type WebsiteTemplateOutputCreate struct {
TemplateID uint `json:"templateID" validate:"required"`
Name string `json:"name" validate:"required"`
VariableValues map[string]string `json:"variableValues"`
}
type WebsitePreviewReq struct {
TemplateID uint `json:"templateID" validate:"required"`
VariableValues map[string]string `json:"variableValues"`
}
+30 -28
View File
@@ -104,34 +104,36 @@ type AppDetail struct {
} }
type AppInstallDTO struct { type AppInstallDTO struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
AppID uint `json:"appID"` AppID uint `json:"appID"`
AppDetailID uint `json:"appDetailID"` AppDetailID uint `json:"appDetailID"`
Version string `json:"version"` Version string `json:"version"`
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
HttpPort int `json:"httpPort"` HttpPort int `json:"httpPort"`
HttpsPort int `json:"httpsPort"` HttpsPort int `json:"httpsPort"`
Path string `json:"path"` Path string `json:"path"`
CanUpdate bool `json:"canUpdate"` CanUpdate bool `json:"canUpdate"`
Icon string `json:"icon"` Icon string `json:"icon"`
AppName string `json:"appName"` AppName string `json:"appName"`
Ready int `json:"ready"` Ready int `json:"ready"`
Total int `json:"total"` Total int `json:"total"`
AppKey string `json:"appKey"` AppKey string `json:"appKey"`
AppType string `json:"appType"` AppType string `json:"appType"`
AppStatus string `json:"appStatus"` AppStatus string `json:"appStatus"`
DockerCompose string `json:"dockerCompose"` DockerCompose string `json:"dockerCompose"`
WebUI string `json:"webUI"` WebUI string `json:"webUI"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder"` SortOrder int `json:"sortOrder"`
App AppDetail `json:"app"` App AppDetail `json:"app"`
Container string `json:"container"` Container string `json:"container"`
IsEdit bool `json:"isEdit"` IsEdit bool `json:"isEdit"`
LinkDB bool `json:"linkDB"` LinkDB bool `json:"linkDB"`
ServiceName string `json:"serviceName"` ResourceKeys []string `json:"resourceKeys"`
ServiceName string `json:"serviceName"`
Env map[string]interface{} `json:"env"`
} }
type AppInstallInfo struct { type AppInstallInfo struct {
+14
View File
@@ -16,6 +16,12 @@ type McpServerDTO struct {
Volumes []request.Volume `json:"volumes"` Volumes []request.Volume `json:"volumes"`
} }
type McpServerStatusDTO struct {
ID uint `json:"id"`
Status string `json:"status"`
Message string `json:"message"`
}
type McpBindDomainRes struct { type McpBindDomainRes struct {
Domain string `json:"domain"` Domain string `json:"domain"`
SSLID uint `json:"sslID"` SSLID uint `json:"sslID"`
@@ -24,3 +30,11 @@ type McpBindDomainRes struct {
WebsiteID uint `json:"websiteID"` WebsiteID uint `json:"websiteID"`
ConnUrl string `json:"connUrl"` ConnUrl string `json:"connUrl"`
} }
type McpServerConnectionTestRes struct {
Success bool `json:"success"`
Endpoint string `json:"endpoint"`
OutputTransport string `json:"outputTransport"`
ProtocolVersion string `json:"protocolVersion,omitempty"`
Message string `json:"message"`
}
+27 -7
View File
@@ -17,6 +17,17 @@ type NginxParam struct {
Params []string `json:"params"` Params []string `json:"params"`
} }
// NginxBrotliRes carries the brotli settings together with where they live.
// ManagedExternally is true when the user defined brotli by hand, in which
// case the panel only reports the values and must not write its own copy.
// ManagedUnavailable is true when the panel could not wire the managed
// configuration into nginx.conf at all, so the reported values are inert.
type NginxBrotliRes struct {
Params []NginxParam `json:"params"`
ManagedExternally bool `json:"managedExternally"`
ManagedUnavailable bool `json:"managedUnavailable"`
}
type NginxAuthRes struct { type NginxAuthRes struct {
Enable bool `json:"enable"` Enable bool `json:"enable"`
Items []dto.NginxAuth `json:"items"` Items []dto.NginxAuth `json:"items"`
@@ -69,16 +80,25 @@ type NginxProxyCache struct {
} }
type NginxModule struct { type NginxModule struct {
Name string `json:"name"` Name string `json:"name"`
Script string `json:"script"` Custom bool `json:"custom"`
Packages string `json:"packages"` Script string `json:"script"`
Params string `json:"params"` Packages string `json:"packages"`
Enable bool `json:"enable"` Params string `json:"params"`
Enable bool `json:"enable"`
BuildMode string `json:"buildMode"`
Provider string `json:"provider"`
LoadOrder int `json:"loadOrder"`
BuildStatus string `json:"buildStatus"`
LoadStatus string `json:"loadStatus"`
Artifacts []dto.NginxModuleArtifact `json:"artifacts"`
LastError string `json:"lastError"`
} }
type NginxBuildConfig struct { type NginxBuildConfig struct {
Mirror string `json:"mirror"` Mirror string `json:"mirror"`
Modules []NginxModule `json:"modules"` DynamicSupported bool `json:"dynamicSupported"`
Modules []NginxModule `json:"modules"`
} }
type NginxConfigRes struct { type NginxConfigRes struct {
@@ -0,0 +1,18 @@
package response
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
)
type WebsiteTemplateDTO struct {
model.WebsiteTemplate
}
type WebsiteTemplateOutputDTO struct {
model.WebsiteTemplateOutput
TemplateName string `json:"templateName"`
}
type WebsitePreviewDTO struct {
HTML string `json:"html"`
}
+30
View File
@@ -0,0 +1,30 @@
package dto
import "time"
type RuntimeDiagnosticsSummary struct {
RSS uint64 `json:"rss"`
HeapAlloc uint64 `json:"heapAlloc"`
HeapObjects uint64 `json:"heapObjects"`
Goroutines int `json:"goroutines"`
}
type RuntimeGoroutineGroup struct {
State string `json:"state"`
Top string `json:"top"`
Count int `json:"count"`
Stack []string `json:"stack"`
}
type RuntimeGoroutineSnapshot struct {
Total int `json:"total"`
GroupCount int `json:"groupCount"`
Truncated bool `json:"truncated"`
CapturedAt time.Time `json:"capturedAt"`
Goroutines []RuntimeGoroutineGroup `json:"goroutines"`
}
type RuntimeProfileCreate struct {
Type string `json:"type" validate:"required,oneof=cpu heap goroutine mutex block"`
Duration int `json:"duration" validate:"omitempty,min=5,max=30"`
}
+1 -1
View File
@@ -35,7 +35,7 @@ type SettingUpdate struct {
} }
type AgentSettingUpdate struct { type AgentSettingUpdate struct {
Key string `json:"key" validate:"required,oneof=SystemIP DockerSockPath FileRecycleBin FirewallPortWhiteList"` Key string `json:"key" validate:"required,oneof=SystemIP DockerSockPath FileRecycleBin"`
Value string `json:"value"` Value string `json:"value"`
} }
+11
View File
@@ -0,0 +1,11 @@
package dto
type TerminalSessionClose struct {
ID string `json:"id" validate:"required"`
}
type TerminalSessionRevoke struct {
Scope string `json:"scope" validate:"required,oneof=auth_session user all"`
UserID string `json:"userId"`
AuthSessionID string `json:"authSessionId"`
}
+2
View File
@@ -7,6 +7,8 @@ type AgentAccount struct {
APIKey string `json:"apiKey"` APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
APIType string `json:"apiType"` APIType string `json:"apiType"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
RememberAPIKey bool `json:"rememberApiKey"` RememberAPIKey bool `json:"rememberApiKey"`
Verified bool `json:"verified"` Verified bool `json:"verified"`
Remark string `json:"remark"` Remark string `json:"remark"`
+4 -8
View File
@@ -2,14 +2,10 @@ package model
type AgentAccountModel struct { type AgentAccountModel struct {
BaseModel BaseModel
AccountID uint `json:"accountId" gorm:"index"` AccountID uint `json:"accountId" gorm:"index"`
Model string `json:"model" gorm:"index"` Model string `json:"model" gorm:"index"`
Name string `json:"name"` Name string `json:"name"`
ContextWindow int `json:"contextWindow"` SortOrder int `json:"sortOrder" gorm:"index"`
MaxTokens int `json:"maxTokens"`
Reasoning bool `json:"reasoning"`
Input string `json:"input" gorm:"type:text"`
SortOrder int `json:"sortOrder" gorm:"index"`
} }
func (AgentAccountModel) TableName() string { func (AgentAccountModel) TableName() string {
+27 -10
View File
@@ -1,5 +1,12 @@
package model package model
import (
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Alert struct { type Alert struct {
BaseModel BaseModel
@@ -18,10 +25,11 @@ type Alert struct {
type AlertTask struct { type AlertTask struct {
BaseModel BaseModel
Type string `gorm:"type:varchar(64);not null" json:"type"` Type string `gorm:"type:varchar(64);not null" json:"type"`
Quota string `gorm:"type:varchar(64)" json:"quota"` Quota string `gorm:"type:varchar(64)" json:"quota"`
QuotaType string `gorm:"type:varchar(64)" json:"quotaType"` QuotaType string `gorm:"type:varchar(64)" json:"quotaType"`
Method string `gorm:"type:varchar(128);not null;default:'sms'" json:"method"` Method string `gorm:"type:varchar(128);not null;default:'sms'" json:"method"`
DeliveryLogID *uint `gorm:"uniqueIndex" json:"-"`
} }
type AlertLog struct { type AlertLog struct {
@@ -41,12 +49,21 @@ type AlertLog struct {
type AlertConfig struct { type AlertConfig struct {
BaseModel BaseModel
Type string `gorm:"type:varchar(64);not null" json:"type"` UID string `gorm:"type:varchar(64);not null;uniqueIndex" json:"uid"`
Title string `gorm:"type:varchar(64);not null" json:"title"` Type string `gorm:"type:varchar(64);not null" json:"type"`
Status string `gorm:"type:varchar(64);not null" json:"status"` Title string `gorm:"type:varchar(64);not null" json:"title"`
Config string `gorm:"type:varchar(256);not null" json:"config"` Status string `gorm:"type:varchar(64);not null" json:"status"`
CreateUser string `gorm:"type:varchar(256)" json:"createUser"` Config string `gorm:"type:text;not null" json:"config"`
UpdateUser string `gorm:"type:varchar(256)" json:"updateUser"` SecretConfig string `gorm:"type:text;not null;default:''" json:"-"`
CreateUser string `gorm:"type:varchar(256)" json:"createUser"`
UpdateUser string `gorm:"type:varchar(256)" json:"updateUser"`
}
func (a *AlertConfig) BeforeCreate(_ *gorm.DB) error {
if strings.TrimSpace(a.UID) == "" {
a.UID = uuid.NewString()
}
return nil
} }
type LoginLog struct { type LoginLog struct {
+1
View File
@@ -31,4 +31,5 @@ type BackupRecord struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
Description string `json:"description"` Description string `json:"description"`
Args string `gorm:"not null;default:''" json:"args"`
} }
+3 -2
View File
@@ -11,6 +11,7 @@ type ComposeTemplate struct {
type Compose struct { type Compose struct {
BaseModel BaseModel
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
IsPinned bool `json:"isPinned"`
} }
+21
View File
@@ -0,0 +1,21 @@
package model
type DatabaseUser struct {
BaseModel
Type string `json:"type" gorm:"not null;uniqueIndex:idx_database_user"`
Database string `json:"database" gorm:"not null;uniqueIndex:idx_database_user"`
Username string `json:"username" gorm:"not null;uniqueIndex:idx_database_user"`
Host string `json:"host" gorm:"uniqueIndex:idx_database_user"`
Password string `json:"password"`
Description string `json:"description"`
IsDelete bool `json:"isDelete"`
}
type DatabaseUserGrant struct {
BaseModel
Type string `json:"type" gorm:"not null;uniqueIndex:idx_database_user_grant"`
Database string `json:"database" gorm:"not null;uniqueIndex:idx_database_user_grant"`
DBName string `json:"dbName" gorm:"not null;uniqueIndex:idx_database_user_grant"`
Username string `json:"username" gorm:"not null;uniqueIndex:idx_database_user_grant"`
Host string `json:"host" gorm:"not null;uniqueIndex:idx_database_user_grant"`
}
+217 -13
View File
@@ -1,18 +1,222 @@
package model package model
type Firewall struct { 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 BaseModel
Type string `json:"type"` UUID string `gorm:"size:64;not null;uniqueIndex" json:"uuid"`
Port string `json:"port"` // Deprecated ReadOnly bool `gorm:"not null;default:false;uniqueIndex:idx_docker_port_guard_endpoint" json:"-"`
Address string `json:"address"` // Deprecated 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"`
Chain string `json:"chain"` HostPort uint16 `gorm:"not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostPort"`
Protocol string `json:"protocol"` Protocol string `gorm:"size:8;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"protocol"`
SrcIP string `json:"srcIP"` Mode string `gorm:"size:32;not null" json:"mode"`
SrcPort string `json:"srcPort"` Sources string `gorm:"type:text" json:"-"`
DstIP string `json:"dstIP"` Description string `gorm:"type:text" json:"description"`
DstPort string `json:"dstPort"` NativeAction string `gorm:"size:32;not null;default:''" json:"-"`
Strategy string `gorm:"not null" json:"strategy"` NativeRules string `gorm:"type:text" json:"-"`
Description string `json:"description"` 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
} }
+2
View File
@@ -8,4 +8,6 @@ type Ftp struct {
Status string `gorm:"not null" json:"status"` Status string `gorm:"not null" json:"status"`
Path string `gorm:"not null" json:"path"` Path string `gorm:"not null" json:"path"`
Description string `gorm:"not null" json:"description"` Description string `gorm:"not null" json:"description"`
UID uint `gorm:"column:uid;not null;default:1000" json:"-"`
GID uint `gorm:"column:gid;not null;default:1000" json:"-"`
} }
+3
View File
@@ -18,4 +18,7 @@ type McpServer struct {
StreamableHttpPath string `json:"streamableHttpPath"` StreamableHttpPath string `json:"streamableHttpPath"`
OutputTransport string `json:"outputTransport"` OutputTransport string `json:"outputTransport"`
Type string `json:"type"` Type string `json:"type"`
GatewayImage string `json:"gatewayImage"`
ProtocolVersion string `json:"protocolVersion"`
GatewayArgs string `json:"gatewayArgs"`
} }
+28
View File
@@ -0,0 +1,28 @@
package model
type WebsiteTemplate struct {
BaseModel
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // single | multi
Content string `gorm:"type:longtext" json:"content"`
FilePath string `json:"filePath"`
Variables string `gorm:"type:text" json:"variables"`
Remark string `json:"remark"`
}
func (w WebsiteTemplate) TableName() string {
return "website_templates"
}
type WebsiteTemplateOutput struct {
BaseModel
Name string `gorm:"not null" json:"name"`
TemplateID uint `gorm:"not null" json:"templateID"`
TemplateType string `json:"templateType"`
VariableValues string `gorm:"type:text" json:"variableValues"`
OutputPath string `json:"outputPath"`
}
func (w WebsiteTemplateOutput) TableName() string {
return "website_template_outputs"
}
+376 -324
View File
@@ -1,319 +1,262 @@
package provider package provider
import ( import (
"fmt"
"net/url"
"strings" "strings"
) )
type Model struct { type APIConfig struct {
ID string APIType string
Name string BaseURL string
ContextWindow int EditableBaseURL bool
MaxTokens int DiscoverModels bool
Reasoning bool DefaultAuthMode string
Input []string AuthModes []string
Models []Model
} }
type RuntimeDefault struct { const (
APIType string AuthModeBearer = "bearer"
ContextWindow int AuthModeXAPIKey = "x-api-key"
MaxTokens int )
Input []string
type Model struct {
ID string
Name string
} }
type Meta struct { type Meta struct {
Key string Key string
DisplayName string DisplayName string
DisplayNameKey string
Sort uint Sort uint
DefaultBaseURL string DefaultAPIType string
APIConfigs []APIConfig
EnvKey string EnvKey string
Default RuntimeDefault
Models []Model Models []Model
} }
var catalog = map[string]Meta{ var catalog = map[string]Meta{
"custom": { "custom": {
Key: "custom", Key: "custom", DisplayName: "Custom", Sort: 10, DefaultAPIType: "openai-completions", EnvKey: "CUSTOM_API_KEY",
DisplayName: "Custom", APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "dashscope-images", "openai-embeddings"),
Sort: 10,
DefaultBaseURL: "",
EnvKey: "CUSTOM_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{},
}, },
"ollama": { "ollama": {
Key: "ollama", Key: "ollama", DisplayName: "Ollama", Sort: 15, DefaultAPIType: "openai-responses",
DisplayName: "Ollama", APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions", "openai-embeddings"),
Sort: 15, },
Default: RuntimeDefault{ // llmman (https://github.com/llmmanorg/llmman): local runner with Ollama/OpenAI-compatible routes on 127.0.0.1:17434.
APIType: "openai-responses", "llmman": {
ContextWindow: 160000, Key: "llmman", DisplayName: "llmman", Sort: 16, DefaultAPIType: "openai-responses",
MaxTokens: 8192, APIConfigs: []APIConfig{
Input: []string{"text"}, {APIType: "openai-responses", BaseURL: "http://127.0.0.1:17434/v1", EditableBaseURL: true},
{APIType: "openai-completions", BaseURL: "http://127.0.0.1:17434/v1", EditableBaseURL: true},
{APIType: "openai-embeddings", BaseURL: "http://127.0.0.1:17434/v1", EditableBaseURL: true},
}, },
}, },
"vllm": { "vllm": {
Key: "vllm", Key: "vllm", DisplayName: "vLLM", Sort: 20, DefaultAPIType: "openai-completions", EnvKey: "VLLM_API_KEY",
DisplayName: "vLLM", APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "openai-embeddings"),
Sort: 20,
DefaultBaseURL: "",
EnvKey: "VLLM_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{},
}, },
"deepseek": { "deepseek": {
Key: "deepseek", Key: "deepseek", DisplayName: "DeepSeek", Sort: 25, DefaultAPIType: "openai-completions", EnvKey: "DEEPSEEK_API_KEY",
DisplayName: "DeepSeek", APIConfigs: []APIConfig{
Sort: 25, {APIType: "openai-completions", BaseURL: "https://api.deepseek.com"},
DefaultBaseURL: "https://api.deepseek.com/v1", {APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
EnvKey: "DEEPSEEK_API_KEY", anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 131072,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "deepseek/deepseek-v4-flash", Name: "deepseek-v4-flash"},
{ID: "deepseek/deepseek-v4-pro", Name: "deepseek-v4-pro"},
}, },
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
}, },
"bailian-coding-plan": { "bailian-coding-plan": {
Key: "bailian-coding-plan", Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
DisplayName: "阿里云百炼 Coding Plan", APIConfigs: []APIConfig{
Sort: 30, {APIType: "openai-completions", BaseURL: "https://coding.dashscope.aliyuncs.com/v1"},
DefaultBaseURL: "https://coding.dashscope.aliyuncs.com/v1", anthropicAPIConfig("https://coding.dashscope.aliyuncs.com/apps/anthropic", AuthModeBearer),
EnvKey: "QWEN_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text"},
}, },
Models: []Model{ Models: []Model{
{ID: "bailian-coding-plan/qwen3.5-plus", Name: "Qwen3.5-Plus", Reasoning: true}, {ID: "qwen3-coder-plus", Name: "Qwen3-Coder-Plus"},
{ID: "bailian-coding-plan/qwen3-max", Name: "Qwen3-Max", Reasoning: true}, {ID: "qwen3-max-2026-01-23", Name: "Qwen3-Max-2026-01-23"},
{ID: "bailian-coding-plan/qwen3-coder-next", Name: "Qwen3-Coder-Next", Reasoning: true}, {ID: "qwen3-coder-next", Name: "Qwen3-Coder-Next"},
{ID: "bailian-coding-plan/qwen3-coder-plus", Name: "Qwen3-Coder-Plus", Reasoning: true}, {ID: "glm-4.7", Name: "GLM-4.7"},
{ID: "bailian-coding-plan/minimax-m2.5", Name: "MiniMax M2.5", Reasoning: true}, {ID: "kimi-k2.5", Name: "Kimi K2.5"},
{ID: "bailian-coding-plan/glm-5", Name: "GLM-5", Reasoning: true}, {ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"},
{ID: "bailian-coding-plan/kimi-k2.5", Name: "Kimi-k2.5", Reasoning: true}, {ID: "glm-5", Name: "GLM-5"},
{ID: "bailian-coding-plan/glm-4.7", Name: "GLM-4.7", Reasoning: true}, {ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
{ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"},
{ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"},
}, },
}, },
"ark-coding-plan": { "ark-coding-plan": {
Key: "ark-coding-plan", Key: "ark-coding-plan", DisplayNameKey: "AIProviderArkCodingPlan", Sort: 35, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
DisplayName: "方舟 Coding Plan", APIConfigs: []APIConfig{
Sort: 35, {APIType: "openai-completions", BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3"},
DefaultBaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3", anthropicAPIConfig("https://ark.cn-beijing.volces.com/api/coding", AuthModeBearer),
EnvKey: "ARK_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 4096,
Input: []string{"text"},
}, },
Models: []Model{ Models: []Model{
{ID: "ark-coding-plan/ark-code-latest", Name: "Ark Coding Plan", ContextWindow: 256000, MaxTokens: 4096}, {ID: "ark-code-latest", Name: "Ark Coding Plan"}, {ID: "doubao-seed-code", Name: "Doubao Seed Code"},
{ID: "ark-coding-plan/doubao-seed-code", Name: "Doubao Seed Code", ContextWindow: 256000, MaxTokens: 4096}, {ID: "glm-4.7", Name: "GLM 4.7 Coding"}, {ID: "kimi-k2-thinking", Name: "Kimi K2 Thinking"},
{ID: "ark-coding-plan/glm-4.7", Name: "GLM 4.7 Coding", ContextWindow: 200000, MaxTokens: 4096}, {ID: "kimi-k2.5", Name: "Kimi K2.5 Coding"}, {ID: "doubao-seed-code-preview-251028", Name: "Doubao Seed Code Preview"},
{ID: "ark-coding-plan/kimi-k2-thinking", Name: "Kimi K2 Thinking", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/kimi-k2.5", Name: "Kimi K2.5 Coding", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/doubao-seed-code-preview-251028", Name: "Doubao Seed Code Preview", ContextWindow: 256000, MaxTokens: 4096},
}, },
}, },
"zai": { "zai": {
Key: "zai", Key: "zai", DisplayName: "Z.ai", Sort: 40, DefaultAPIType: "openai-completions", EnvKey: "ZAI_API_KEY",
DisplayName: "Z.ai", APIConfigs: []APIConfig{
Sort: 40, {APIType: "openai-completions", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true},
DefaultBaseURL: "https://open.bigmodel.cn/api/paas/v4", {APIType: "openai-images", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true},
EnvKey: "ZAI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 204800,
MaxTokens: 131072,
Input: []string{"text"},
},
Models: []Model{
{ID: "zai/glm-5", Name: "GLM-5", Reasoning: true},
{ID: "zai/glm-4.7", Name: "GLM-4.7", Reasoning: true},
{ID: "zai/glm-4.7-flash", Name: "GLM-4.7-Flash", Reasoning: true},
{ID: "zai/glm-4.7-flashx", Name: "GLM-4.7-FlashX", Reasoning: true},
}, },
Models: []Model{{ID: "glm-5", Name: "GLM-5"}, {ID: "glm-4.7", Name: "GLM-4.7"}, {ID: "glm-4.7-flash", Name: "GLM-4.7-Flash"}, {ID: "glm-4.7-flashx", Name: "GLM-4.7-FlashX"}},
}, },
"minimax": { "minimax": {
Key: "minimax", Key: "minimax", DisplayName: "MiniMax (CN)", Sort: 45, DefaultAPIType: "anthropic-messages", EnvKey: "MINIMAX_API_KEY",
DisplayName: "MiniMax (CN)", APIConfigs: []APIConfig{
Sort: 45, anthropicAPIConfig("https://api.minimaxi.com/anthropic", AuthModeXAPIKey, AuthModeBearer),
DefaultBaseURL: "https://api.minimaxi.com/anthropic", {APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
EnvKey: "MINIMAX_API_KEY", {APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
Default: RuntimeDefault{
APIType: "anthropic-messages",
ContextWindow: 200000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "minimax/MiniMax-M2.7", Name: "MiniMax M2.7"},
{ID: "minimax/MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"},
{ID: "minimax/MiniMax-M2.5", Name: "MiniMax M2.5", Reasoning: true},
{ID: "minimax/MiniMax-M2.5-highspeed", Name: "MiniMax M2.5 highspeed"},
}, },
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"}},
}, },
"xiaomi": { "xiaomi": {
Key: "xiaomi", Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
DisplayName: "Xiaomi", APIConfigs: []APIConfig{
Sort: 46, {APIType: "openai-completions", BaseURL: "https://api.xiaomimimo.com/v1"},
DefaultBaseURL: "https://api.xiaomimimo.com/v1", {APIType: "openai-responses", BaseURL: "https://api.xiaomimimo.com/v1"},
EnvKey: "XIAOMI_API_KEY", anthropicAPIConfig("https://api.xiaomimimo.com/anthropic", AuthModeBearer),
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 262144,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "xiaomi/mimo-v2-flash", Name: "Xiaomi MiMo V2 Flash", ContextWindow: 262144, MaxTokens: 8192, Input: []string{"text"}},
{ID: "xiaomi/mimo-v2-pro", Name: "Xiaomi MiMo V2 Pro", ContextWindow: 1048576, MaxTokens: 32000, Reasoning: true, Input: []string{"text"}},
{ID: "xiaomi/mimo-v2-omni", Name: "Xiaomi MiMo V2 Omni", ContextWindow: 262144, MaxTokens: 32000, Reasoning: true, Input: []string{"text", "image"}},
}, },
Models: []Model{{ID: "mimo-v2.5", Name: "Xiaomi MiMo V2.5"}, {ID: "mimo-v2.5-pro", Name: "Xiaomi MiMo V2.5 Pro"}},
}, },
"kimi": { "kimi": {
Key: "kimi", Key: "kimi", DisplayName: "Kimi (CN)", Sort: 50, DefaultAPIType: "openai-completions", EnvKey: "KIMI_API_KEY",
DisplayName: "Kimi (CN)", APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://api.moonshot.cn/v1"}},
Sort: 50, Models: []Model{{ID: "kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, {ID: "kimi-k2-thinking", Name: "Kimi K2 Thinking"}},
DefaultBaseURL: "https://api.moonshot.cn/v1",
EnvKey: "KIMI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "kimi/kimi-k2.5", Name: "Kimi K2.5", Reasoning: true},
{ID: "kimi/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"},
{ID: "kimi/kimi-k2-thinking", Name: "Kimi K2 Thinking", Reasoning: true},
},
}, },
"kimi-coding": { "kimi-coding": {
Key: "kimi-coding", Key: "kimi-coding", DisplayName: "Kimi Coding", Sort: 51, DefaultAPIType: "anthropic-messages", EnvKey: "KIMI_API_KEY",
DisplayName: "Kimi Coding", APIConfigs: []APIConfig{anthropicAPIConfig("https://api.kimi.com/coding/", AuthModeXAPIKey)},
Sort: 51, Models: []Model{{ID: "kimi-code", Name: "Kimi Code"}, {ID: "k2p5", Name: "Kimi K2.5"}},
DefaultBaseURL: "https://api.kimi.com/coding/",
EnvKey: "KIMI_API_KEY",
Default: RuntimeDefault{
APIType: "anthropic-messages",
ContextWindow: 262144,
MaxTokens: 32768,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "kimi-coding/kimi-code", Name: "Kimi Code", ContextWindow: 262144, MaxTokens: 32768, Reasoning: true, Input: []string{"text", "image"}},
{ID: "kimi-coding/k2p5", Name: "Kimi K2.5", ContextWindow: 262144, MaxTokens: 32768, Reasoning: true, Input: []string{"text", "image"}},
},
}, },
"openai": { "openai": {
Key: "openai", Key: "openai", DisplayName: "OpenAI", Sort: 55, DefaultAPIType: "openai-responses", EnvKey: "OPENAI_API_KEY",
DisplayName: "OpenAI", APIConfigs: []APIConfig{
Sort: 55, {APIType: "openai-responses", BaseURL: "https://api.openai.com/v1"},
DefaultBaseURL: "https://api.openai.com/v1", {APIType: "openai-completions", BaseURL: "https://api.openai.com/v1"},
EnvKey: "OPENAI_API_KEY", {APIType: "openai-images", BaseURL: "https://api.openai.com/v1"},
Default: RuntimeDefault{ {APIType: "openai-embeddings", BaseURL: "https://api.openai.com/v1", Models: []Model{
APIType: "openai-responses", {ID: "text-embedding-3-small", Name: "text-embedding-3-small"},
ContextWindow: 272000, {ID: "text-embedding-3-large", Name: "text-embedding-3-large"},
MaxTokens: 128000, }},
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "openai/gpt-5.4", Name: "gpt-5.4", ContextWindow: 272000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-pro", Name: "gpt-5.4-pro", ContextWindow: 1050000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-mini", Name: "gpt-5.4-mini", ContextWindow: 400000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-nano", Name: "gpt-5.4-nano", ContextWindow: 400000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
}, },
Models: []Model{{ID: "gpt-5.4", Name: "gpt-5.4"}, {ID: "gpt-5.4-pro", Name: "gpt-5.4-pro"}, {ID: "gpt-5.4-mini", Name: "gpt-5.4-mini"}, {ID: "gpt-5.4-nano", Name: "gpt-5.4-nano"}},
}, },
"openrouter": { "openrouter": {
Key: "openrouter", Key: "openrouter", DisplayName: "OpenRouter", Sort: 56, DefaultAPIType: "openai-completions", EnvKey: "OPENROUTER_API_KEY",
DisplayName: "OpenRouter", APIConfigs: []APIConfig{
Sort: 56, {APIType: "openai-completions", BaseURL: "https://openrouter.ai/api/v1"},
DefaultBaseURL: "https://openrouter.ai/api/v1", {APIType: "openrouter-images", BaseURL: "https://openrouter.ai"},
EnvKey: "OPENROUTER_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "openrouter/free", Name: "openrouter/free"},
{ID: "openrouter/auto", Name: "openrouter/auto"},
}, },
Models: []Model{{ID: "openrouter/free", Name: "openrouter/free"}, {ID: "openrouter/auto", Name: "openrouter/auto"}},
}, },
"anthropic": { "anthropic": {
Key: "anthropic", Key: "anthropic", DisplayName: "Anthropic", Sort: 60, DefaultAPIType: "anthropic-messages", EnvKey: "ANTHROPIC_API_KEY",
DisplayName: "Anthropic", APIConfigs: []APIConfig{anthropicAPIConfig("https://api.anthropic.com", AuthModeXAPIKey)},
Sort: 60, Models: []Model{{ID: "claude-sonnet-4-6", Name: "Claude Sonnet 4.6"}, {ID: "claude-opus-4-6", Name: "Claude Opus 4.6"}, {ID: "claude-opus-4-5", Name: "Claude Opus 4.5"}, {ID: "claude-sonnet-4-5", Name: "Claude Sonnet 4.5"}, {ID: "claude-haiku-4-5", Name: "Claude Haiku 4.5"}},
DefaultBaseURL: "https://api.anthropic.com",
EnvKey: "ANTHROPIC_API_KEY",
Default: RuntimeDefault{
APIType: "anthropic-messages",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "anthropic/claude-sonnet-4-6", Name: "Claude Sonnet 4.6", Reasoning: true},
{ID: "anthropic/claude-opus-4-6", Name: "Claude Opus 4.6", Reasoning: true},
{ID: "anthropic/claude-opus-4-5", Name: "Claude Opus 4.5"},
{ID: "anthropic/claude-sonnet-4-5", Name: "Claude Sonnet 4.5"},
{ID: "anthropic/claude-haiku-4-5", Name: "Claude Haiku 4.5"},
},
}, },
"gemini": { "gemini": {
Key: "gemini", Key: "gemini", DisplayName: "Gemini", Sort: 65, DefaultAPIType: "gemini-generate-content", EnvKey: "GEMINI_API_KEY",
DisplayName: "Gemini", APIConfigs: []APIConfig{{APIType: "gemini-generate-content", BaseURL: "https://generativelanguage.googleapis.com"}},
Sort: 65, Models: []Model{{ID: "gemini-3-flash-preview", Name: "Gemini 3 Flash Preview"}, {ID: "gemini-flash-latest", Name: "Gemini Flash Latest"}, {ID: "gemini-3-pro-preview", Name: "Gemini 3 Pro Preview"}},
DefaultBaseURL: "https://generativelanguage.googleapis.com",
EnvKey: "GEMINI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "google/gemini-3-flash-preview", Name: "Gemini 3 Flash Preview", Reasoning: true},
{ID: "google/gemini-flash-latest", Name: "Gemini Flash Latest"},
{ID: "google/gemini-3-pro-preview", Name: "Gemini 3 Pro Preview", Reasoning: true},
},
}, },
"moonshot": { "moonshot": {
Key: "moonshot", Key: "moonshot", DisplayName: "Moonshot (Global)", Sort: 70, DefaultAPIType: "openai-completions", EnvKey: "MOONSHOT_API_KEY",
DisplayName: "Moonshot (Global)", APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://api.moonshot.ai/v1"}},
Sort: 70, Models: []Model{{ID: "kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, {ID: "kimi-k2-thinking", Name: "Kimi K2 Thinking"}},
DefaultBaseURL: "https://api.moonshot.ai/v1", },
EnvKey: "MOONSHOT_API_KEY", "bailian": {
Default: RuntimeDefault{ Key: "bailian", DisplayNameKey: "AIProviderBailian", Sort: 31, DefaultAPIType: "openai-completions", EnvKey: "DASHSCOPE_API_KEY",
APIType: "openai-completions", APIConfigs: []APIConfig{
ContextWindow: 256000, {
MaxTokens: 8192, APIType: "openai-completions", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
Input: []string{"text"}, DiscoverModels: true,
}, Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
Models: []Model{ },
{ID: "moonshot/kimi-k2.5", Name: "Kimi K2.5", Reasoning: true}, {
{ID: "moonshot/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, APIType: "openai-responses", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
{ID: "moonshot/kimi-k2-thinking", Name: "Kimi K2 Thinking", Reasoning: true}, DiscoverModels: true,
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
},
{
APIType: "anthropic-messages", BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
DefaultAuthMode: AuthModeBearer,
AuthModes: []string{AuthModeBearer},
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
},
{
APIType: "dashscope-images", BaseURL: "https://dashscope.aliyuncs.com",
Models: []Model{
{ID: "qwen-image-2.0-pro", Name: "qwen-image-2.0-pro"},
{ID: "qwen-image-2.0", Name: "qwen-image-2.0"},
{ID: "wan2.7-image-pro", Name: "wan2.7-image-pro"},
{ID: "wan2.7-image", Name: "wan2.7-image"},
},
},
}, },
}, },
"ark": {
Key: "ark", DisplayNameKey: "AIProviderArk", Sort: 36, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
APIConfigs: []APIConfig{
{
APIType: "openai-completions", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
DiscoverModels: true,
Models: []Model{{ID: "doubao-seed-2-0-pro-260215", Name: "doubao-seed-2-0-pro-260215"}, {ID: "doubao-seed-2-0-lite-260215", Name: "doubao-seed-2-0-lite-260215"}},
},
{
APIType: "openai-responses", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
DiscoverModels: true,
Models: []Model{{ID: "doubao-seed-2-0-pro-260215", Name: "doubao-seed-2-0-pro-260215"}, {ID: "doubao-seed-2-0-lite-260215", Name: "doubao-seed-2-0-lite-260215"}},
},
{
APIType: "openai-images", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Models: []Model{
{ID: "doubao-seedream-5-0-260128", Name: "doubao-seedream-5-0-260128"},
{ID: "doubao-seedream-5-0-lite-260128", Name: "doubao-seedream-5-0-lite-260128"},
{ID: "doubao-seedream-4-5-251128", Name: "doubao-seedream-4-5-251128"},
},
},
},
},
}
func editableAPIConfigs(discoverModels bool, apiTypes ...string) []APIConfig {
configs := make([]APIConfig, 0, len(apiTypes))
for _, apiType := range apiTypes {
if apiType == "anthropic-messages" {
config := anthropicAPIConfig("", AuthModeXAPIKey, AuthModeBearer)
config.EditableBaseURL = true
configs = append(configs, config)
continue
}
configs = append(configs, APIConfig{
APIType: apiType,
EditableBaseURL: true,
DiscoverModels: discoverModels && (apiType == "openai-completions" || apiType == "openai-responses"),
})
}
return configs
}
func anthropicAPIConfig(baseURL, defaultAuthMode string, additionalAuthModes ...string) APIConfig {
authModes := []string{defaultAuthMode}
for _, authMode := range additionalAuthModes {
if authMode != defaultAuthMode {
authModes = append(authModes, authMode)
}
}
return APIConfig{
APIType: "anthropic-messages",
BaseURL: baseURL,
DefaultAuthMode: defaultAuthMode,
AuthModes: authModes,
}
} }
func Get(key string) (Meta, bool) { func Get(key string) (Meta, bool) {
@@ -332,12 +275,148 @@ func All() map[string]Meta {
return result return result
} }
func DefaultBaseURL(key string) (string, bool) { func FindAPIConfig(key, apiType string) (APIConfig, bool) {
meta, ok := catalog[key] meta, ok := catalog[key]
if !ok || strings.TrimSpace(meta.DefaultBaseURL) == "" { if !ok {
return APIConfig{}, false
}
target := strings.TrimSpace(apiType)
if target == "" {
target = meta.DefaultAPIType
}
for _, config := range meta.APIConfigs {
if config.APIType == target {
config.AuthModes = append([]string(nil), config.AuthModes...)
config.Models = append([]Model(nil), config.Models...)
return config, true
}
}
return APIConfig{}, false
}
func DefaultModels(key, apiType string) []Model {
meta, ok := catalog[key]
if !ok {
return nil
}
target := strings.TrimSpace(apiType)
if target == "" {
target = meta.DefaultAPIType
}
for _, config := range meta.APIConfigs {
if config.APIType != target {
continue
}
if len(config.Models) > 0 {
return append([]Model(nil), config.Models...)
}
if IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType) {
return nil
}
break
}
return append([]Model(nil), meta.Models...)
}
func ResolveAuthMode(provider, apiType, requested string) (string, error) {
config, ok := FindAPIConfig(provider, apiType)
if !ok {
return "", fmt.Errorf("provider %s does not support api type %s", provider, apiType)
}
if config.APIType != "anthropic-messages" {
return "", nil
}
authMode := strings.TrimSpace(requested)
if authMode == "" {
authMode = config.DefaultAuthMode
}
for _, allowed := range config.AuthModes {
if authMode == allowed {
return authMode, nil
}
}
return "", fmt.Errorf("provider %s does not support auth mode %s", provider, authMode)
}
func DefaultBaseURL(key string) (string, bool) {
config, ok := FindAPIConfig(key, "")
if !ok || strings.TrimSpace(config.BaseURL) == "" {
return "", false return "", false
} }
return meta.DefaultBaseURL, true return config.BaseURL, true
}
func DefaultAPIType(key string) string {
meta, ok := catalog[key]
if !ok || strings.TrimSpace(meta.DefaultAPIType) == "" {
return "openai-completions"
}
return meta.DefaultAPIType
}
func ResolveBaseURL(key, apiType, requested string) (string, error) {
config, ok := FindAPIConfig(key, apiType)
if !ok {
return "", fmt.Errorf("provider %s does not support api type %s", key, apiType)
}
if !config.EditableBaseURL {
return strings.TrimRight(config.BaseURL, "/"), nil
}
baseURL := strings.TrimSpace(requested)
if baseURL == "" {
baseURL = config.BaseURL
}
if baseURL == "" {
return "", fmt.Errorf("base url is required")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid base url")
}
if key == "custom" && (IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType)) {
return baseURL, nil
}
parsed.Path = normalizeEndpointPath(config.APIType, parsed.Path)
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimRight(parsed.String(), "/"), nil
}
func normalizeEndpointPath(apiType, value string) string {
if IsImageAPIType(apiType) {
return strings.TrimRight(value, "/")
}
path := strings.TrimRight(value, "/")
suffixes := []string{}
switch apiType {
case "openai-completions":
suffixes = []string{"/chat/completions"}
case "openai-responses":
suffixes = []string{"/responses"}
case "anthropic-messages":
suffixes = []string{"/v1/messages", "/messages"}
case "openai-embeddings":
suffixes = []string{"/v1/embeddings", "/embeddings"}
}
for _, suffix := range suffixes {
if strings.HasSuffix(strings.ToLower(path), suffix) {
return path[:len(path)-len(suffix)]
}
}
return path
}
func IsEmbeddingAPIType(apiType string) bool {
return apiType == "openai-embeddings"
}
func IsImageAPIType(apiType string) bool {
switch apiType {
case "openai-images", "dashscope-images", "minimax-images", "openrouter-images":
return true
default:
return false
}
} }
func EnvKey(key string) string { func EnvKey(key string) string {
@@ -350,89 +429,62 @@ func EnvKey(key string) string {
func DisplayName(key string) string { func DisplayName(key string) string {
meta, ok := catalog[key] meta, ok := catalog[key]
if !ok { if !ok || strings.TrimSpace(meta.DisplayName) == "" {
return key
}
if strings.TrimSpace(meta.DisplayName) == "" {
return key return key
} }
return meta.DisplayName return meta.DisplayName
} }
func FindModel(key, modelID string) (Model, bool) { func DisplayNameKey(key string) string {
meta, ok := Get(key) meta, ok := catalog[key]
if !ok { if !ok {
return Model{}, false return ""
} }
for _, item := range meta.Models { return meta.DisplayNameKey
if item.ID == modelID { }
return item, true
func NormalizeModelID(provider, modelID string) string {
target := strings.TrimLeft(strings.TrimSpace(modelID), "/")
for _, prefix := range legacyModelPrefixes[provider] {
legacyPrefix := prefix + "/"
if !strings.HasPrefix(target, legacyPrefix) {
continue
}
candidate := strings.TrimSpace(strings.TrimPrefix(target, legacyPrefix))
if candidate != "" {
return candidate
} }
} }
return Model{}, false return target
}
var legacyModelPrefixes = map[string][]string{
"custom": {"custom"},
"vllm": {"custom"},
"ollama": {"ollama"},
"llmman": {"llmman"},
"deepseek": {"deepseek"},
"bailian-coding-plan": {"bailian-coding-plan"},
"ark-coding-plan": {"ark-coding-plan"},
"zai": {"zai"},
"minimax": {"minimax"},
"xiaomi": {"xiaomi"},
"kimi": {"kimi", "moonshot"},
"kimi-coding": {"kimi-coding"},
"openai": {"openai"},
"anthropic": {"anthropic"},
"gemini": {"google", "gemini"},
"moonshot": {"moonshot"},
} }
func cloneMeta(meta Meta) Meta { func cloneMeta(meta Meta) Meta {
clone := meta clone := meta
if len(meta.Default.Input) > 0 { clone.APIConfigs = make([]APIConfig, len(meta.APIConfigs))
clone.Default.Input = make([]string, len(meta.Default.Input)) for index, config := range meta.APIConfigs {
copy(clone.Default.Input, meta.Default.Input) clone.APIConfigs[index] = config
} clone.APIConfigs[index].AuthModes = append([]string(nil), config.AuthModes...)
if len(meta.Models) > 0 { clone.APIConfigs[index].Models = append([]Model(nil), config.Models...)
clone.Models = make([]Model, len(meta.Models))
for i, item := range meta.Models {
clone.Models[i] = normalizeModel(meta, item)
}
} }
clone.Models = append([]Model(nil), meta.Models...)
return clone return clone
} }
func normalizeModel(meta Meta, model Model) Model {
clone := model
clone.ID = strings.TrimSpace(clone.ID)
clone.Name = strings.TrimSpace(clone.Name)
if clone.Name == "" {
clone.Name = clone.ID
}
if clone.MaxTokens <= 0 {
clone.MaxTokens = meta.Default.MaxTokens
}
if clone.ContextWindow <= 0 {
clone.ContextWindow = meta.Default.ContextWindow
}
if len(clone.Input) == 0 && len(meta.Default.Input) > 0 {
clone.Input = make([]string, len(meta.Default.Input))
copy(clone.Input, meta.Default.Input)
}
return clone
}
func ResolveRuntimeParams(provider, apiType string, maxTokens, contextWindow int) (string, int, int) {
defaultAPIType := "openai-completions"
defaultMaxTokens := 8192
defaultContextWindow := 256000
if meta, ok := Get(provider); ok {
if meta.Default.APIType != "" {
defaultAPIType = meta.Default.APIType
}
if meta.Default.MaxTokens > 0 {
defaultMaxTokens = meta.Default.MaxTokens
}
if meta.Default.ContextWindow > 0 {
defaultContextWindow = meta.Default.ContextWindow
}
}
resolvedAPI := apiType
if strings.TrimSpace(apiType) == "" {
resolvedAPI = defaultAPIType
}
resolvedMaxTokens := defaultMaxTokens
resolvedContextWindow := defaultContextWindow
if maxTokens > 0 {
resolvedMaxTokens = maxTokens
}
if contextWindow > 0 {
resolvedContextWindow = contextWindow
}
return resolvedAPI, resolvedMaxTokens, resolvedContextWindow
}
+74
View File
@@ -0,0 +1,74 @@
package provider
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
)
func DiscoverModels(baseURL, apiKey string) ([]string, error) {
req, err := http.NewRequest(http.MethodGet, buildModelDiscoveryURL(baseURL), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := (&http.Client{Timeout: defaultVerifyTimeout}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("request failed: %s", resp.Status)
}
var payload struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
models := make([]string, 0, len(payload.Data))
seen := make(map[string]struct{}, len(payload.Data))
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
models = append(models, id)
}
if len(models) == 0 {
return nil, fmt.Errorf("no models found")
}
return models, nil
}
func buildModelDiscoveryURL(baseURL string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
for _, apiType := range []string{"openai-completions", "openai-responses", "anthropic-messages"} {
base = normalizeEndpointPath(apiType, base)
}
switch {
case strings.HasSuffix(base, "/models"):
return base
case hasAPIVersionSuffix(base):
return base + "/models"
default:
return base + "/v1/models"
}
}
func hasAPIVersionSuffix(value string) bool {
segment := value[strings.LastIndex(value, "/")+1:]
if len(segment) < 2 || segment[0] != 'v' {
return false
}
_, err := strconv.Atoi(segment[1:])
return err == nil
}
+34 -54
View File
@@ -15,72 +15,52 @@ type OpenClawProviderPatch struct {
AuthHeader bool AuthHeader bool
} }
func BuildOpenClawProviderPatch(provider, modelName, apiType, baseURL, apiKey string) (*OpenClawProviderPatch, error) { func BuildOpenClawProviderPatch(provider, modelName, apiType, authMode, baseURL, apiKey string) (*OpenClawProviderPatch, error) {
if modelName == "" { if modelName == "" {
return nil, fmt.Errorf("model is required") return nil, fmt.Errorf("model is required")
} }
resolvedAPIType, _, _ := ResolveRuntimeParams(provider, apiType, 0, 0) resolvedAPIType := apiType
modelID := resolveOpenClawModelID(provider, modelName) if _, ok := FindAPIConfig(provider, resolvedAPIType); !ok {
switch provider { resolvedAPIType = DefaultAPIType(provider)
case "deepseek":
return newOpenClawProviderPatch(modelName, "deepseek", modelID, apiKey, baseURL, "openai-completions", false), nil
case "gemini":
return newOpenClawProviderPatch("google/"+modelID, "google", modelID, apiKey, baseURL, resolvedAPIType, false), nil
case "moonshot", "kimi":
return buildMoonshotProviderPatch(provider, modelName, modelID, baseURL, apiKey), nil
case "bailian-coding-plan":
return newOpenClawProviderPatch("bailian-coding-plan/"+modelID, "bailian-coding-plan", modelID, apiKey, baseURL, "openai-completions", false), nil
case "ark-coding-plan":
return newOpenClawProviderPatch("ark-coding-plan/"+modelID, "ark-coding-plan", modelID, apiKey, baseURL, "openai-completions", false), nil
case "minimax":
return newOpenClawProviderPatch("minimax/"+modelID, "minimax", modelID, apiKey, baseURL, "anthropic-messages", true), nil
case "xiaomi":
return newOpenClawProviderPatch("xiaomi/"+modelID, "xiaomi", modelID, apiKey, baseURL, "openai-completions", false), nil
case "custom", "vllm":
return newOpenClawProviderPatch(provider+"/"+modelID, provider, modelID, apiKey, baseURL, resolvedAPIType, false), nil
case "ollama":
return newOpenClawProviderPatch(modelName, "ollama", modelID, "ollama", baseURL, resolvedAPIType, false), nil
case "kimi-coding":
return newOpenClawProviderPatch(modelName, "kimi-coding", modelID, apiKey, baseURL, "anthropic-messages", false), nil
case "zai":
return newOpenClawProviderPatch("zai/"+modelID, "zai", modelID, apiKey, baseURL, "openai-completions", false), nil
default:
return newOpenClawProviderPatch(modelName, provider, modelID, apiKey, baseURL, resolvedAPIType, false), nil
} }
} if IsImageAPIType(resolvedAPIType) || IsEmbeddingAPIType(resolvedAPIType) {
return nil, fmt.Errorf("api type %s does not support text generation", resolvedAPIType)
func buildMoonshotProviderPatch(provider, modelName, modelID, baseURL, apiKey string) *OpenClawProviderPatch { }
resolvedAuthMode, err := ResolveAuthMode(provider, resolvedAPIType, authMode)
if err != nil {
return nil, err
}
usesBearer := resolvedAuthMode == AuthModeBearer
modelID := NormalizeModelID(provider, modelName)
providerKey := provider providerKey := provider
primaryModel := modelName preserveQualifiedModel := false
if provider == "kimi" { switch provider {
case "gemini":
providerKey = "google"
resolvedAPIType = "google-generative-ai"
usesBearer = false
case "moonshot", "kimi":
providerKey = "moonshot" providerKey = "moonshot"
primaryModel = "moonshot/" + modelID resolvedAPIType = "openai-completions"
usesBearer = false
case "ollama", "llmman":
apiKey = provider
usesBearer = false
case "openai", "openrouter", "anthropic":
preserveQualifiedModel = strings.Contains(modelName, "/")
} }
return newOpenClawProviderPatch(primaryModel, providerKey, modelID, apiKey, baseURL, "openai-completions", false)
}
func newOpenClawProviderPatch(primaryModel, providerKey, modelID, apiKey, baseURL, apiType string, authHeader bool) *OpenClawProviderPatch { primaryModel := providerKey + "/" + modelID
if preserveQualifiedModel {
primaryModel = modelName
}
return &OpenClawProviderPatch{ return &OpenClawProviderPatch{
PrimaryModel: primaryModel, PrimaryModel: primaryModel,
ProviderKey: providerKey, ProviderKey: providerKey,
ModelID: modelID, ModelID: modelID,
APIKey: apiKey, APIKey: apiKey,
BaseURL: baseURL, BaseURL: baseURL,
APIType: apiType, APIType: resolvedAPIType,
AuthHeader: authHeader, AuthHeader: usesBearer,
} }, nil
}
func resolveOpenClawModelID(provider, modelName string) string {
if provider == "custom" || provider == "vllm" {
target := strings.TrimLeft(modelName, "/")
if parts := strings.SplitN(target, "/", 2); len(parts) == 2 && parts[0] == "custom" {
return strings.TrimLeft(parts[1], "/")
}
return target
}
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
return parts[1]
}
return modelName
} }
+116 -116
View File
@@ -3,7 +3,9 @@ package provider
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -18,160 +20,158 @@ type VerifyRequest struct {
Body []byte Body []byte
} }
type verifyErrorResponse struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
Message string `json:"message"`
}
const ( const (
defaultVerifyTimeout = 30 * time.Second defaultVerifyTimeout = 30 * time.Second
defaultVerifyMaxTokens = 16
) )
func SkipVerification(key string) bool { func SkipVerification(provider string) bool {
switch key { switch provider {
case "custom", "vllm", "ollama", "kimi-coding": case "vllm", "ollama", "llmman", "kimi-coding":
return true return true
default: default:
return false return false
} }
} }
func VerifyAccount(provider, baseURL, apiKey string) error { func VerifyAccount(provider, apiType, authMode, baseURL, apiKey, model string) error {
req := BuildVerifyRequest(provider, baseURL, apiKey) req := BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model)
var body *bytes.Buffer httpReq, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(req.Body))
if len(req.Body) > 0 {
body = bytes.NewBuffer(req.Body)
} else {
body = bytes.NewBuffer(nil)
}
httpReq, err := http.NewRequest(req.Method, req.URL, body)
if err != nil { if err != nil {
return err return err
} }
for key, value := range req.Headers { for key, value := range req.Headers {
httpReq.Header.Set(key, value) httpReq.Header.Set(key, value)
} }
resp, err := (&http.Client{Timeout: verifyTimeout()}).Do(httpReq) httpReq.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: defaultVerifyTimeout}).Do(httpReq)
if err != nil { if err != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", err) return buserr.WithErr("ErrAgentAccountUnavailable", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return buserr.WithErr("ErrAgentAccountUnavailable", fmt.Errorf("verify failed: %s", resp.Status)) body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if readErr != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", readErr)
}
return buserr.WithErr("ErrAgentAccountUnavailable", errors.New(verifyHTTPError(resp.StatusCode, body)))
} }
return nil return nil
} }
func verifyTimeout() time.Duration { func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model string) VerifyRequest {
return defaultVerifyTimeout baseURL = strings.TrimSpace(baseURL)
} if provider != "custom" || !IsImageAPIType(apiType) {
baseURL = strings.TrimRight(baseURL, "/")
}
headers := map[string]string{"Content-Type": "application/json"}
request := VerifyRequest{Method: http.MethodPost, Headers: headers}
func BuildVerifyRequest(provider, baseURL, apiKey string) VerifyRequest { if provider == "gemini" {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/") request.URL = baseURL + "/v1beta/models/" + strings.TrimSpace(model) + ":generateContent"
headers := map[string]string{}
request := VerifyRequest{Method: http.MethodGet, Headers: headers}
switch provider {
case "anthropic", "kimi-coding":
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01"
if strings.Contains(base, "/v1") {
request.URL = base + "/models"
} else {
request.URL = base + "/v1/models"
}
case "gemini":
request.Method = http.MethodPost
if strings.Contains(base, "/v1beta") {
request.URL = base + "/models/gemini-3-flash-preview:generateContent"
} else {
request.URL = base + "/v1beta/models/gemini-3-flash-preview:generateContent"
}
headers["x-goog-api-key"] = apiKey headers["x-goog-api-key"] = apiKey
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{ request.Body = mustJSON(map[string]interface{}{
"contents": []map[string]interface{}{{ "contents": []map[string]interface{}{{"parts": []map[string]string{{"text": "test"}}}},
"parts": []map[string]string{{ })
"text": "Explain how AI works in a few words", return request
}}, }
switch apiType {
case "openai-embeddings":
request.URL = embeddingVerifyURL(baseURL)
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "input": "ping"})
case "openai-images":
request.URL = imageVerifyURL(provider, baseURL, "/images/generations")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
case "dashscope-images":
request.URL = imageVerifyURL(provider, baseURL, "/api/v1/services/aigc/multimodal-generation/generation")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{
"model": model,
"input": map[string]interface{}{"messages": []map[string]interface{}{
{"role": "user", "content": []map[string]string{{"text": "test"}}},
}}, }},
"parameters": map[string]interface{}{"n": 1},
}) })
case "zai": case "minimax-images":
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey) request.URL = imageVerifyURL(provider, baseURL, "/v1/image_generation")
request.URL = base + "/models" headers["Authorization"] = "Bearer " + apiKey
case "bailian-coding-plan": request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
request.Method = http.MethodPost case "openrouter-images":
if !strings.Contains(base, "/v1") { request.URL = imageVerifyURL(provider, baseURL, "/api/v1/images")
base = base + "/v1" headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
case "anthropic-messages":
request.URL = baseURL + "/v1/messages"
if authMode == AuthModeBearer {
headers["Authorization"] = "Bearer " + apiKey
} else {
headers["x-api-key"] = apiKey
} }
request.URL = base + "/chat/completions"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "qwen3.5-plus",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
case "ark-coding-plan":
request.Method = http.MethodPost
if !strings.Contains(base, "/api/coding/v3") {
base = "https://ark.cn-beijing.volces.com/api/coding/v3"
}
request.URL = base + "/chat/completions"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "ark-code-latest",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
case "minimax":
request.Method = http.MethodPost
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01" headers["anthropic-version"] = "2023-06-01"
headers["Content-Type"] = "application/json"
if strings.Contains(base, "/v1") {
request.URL = base + "/messages"
} else {
request.URL = base + "/v1/messages"
}
request.Body = mustJSON(map[string]interface{}{ request.Body = mustJSON(map[string]interface{}{
"model": "MiniMax-M2.5", "model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false,
"max_tokens": 1, "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",
}},
}},
}) })
case "xiaomi": case "openai-responses":
request.Method = http.MethodPost request.URL = baseURL + "/responses"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey) headers["Authorization"] = "Bearer " + apiKey
headers["Content-Type"] = "application/json" request.Body = mustJSON(map[string]interface{}{"model": model, "input": "test", "max_output_tokens": defaultVerifyMaxTokens, "stream": false})
if !strings.Contains(base, "/v1") {
base = base + "/v1"
}
request.URL = base + "/chat/completions"
request.Body = mustJSON(map[string]interface{}{
"model": "mimo-v2-flash",
"max_tokens": 1,
"messages": []map[string]string{{"role": "user", "content": "test"}},
})
case "openrouter":
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
if strings.Contains(base, "/v1") {
request.URL = base + "/key"
} else {
request.URL = base + "/v1/key"
}
default: default:
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey) request.URL = baseURL + "/chat/completions"
if strings.Contains(base, "/v1") { if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" {
request.URL = base + "/models" headers["Authorization"] = "Bearer " + apiKey
} else {
request.URL = base + "/v1/models"
} }
request.Body = mustJSON(map[string]interface{}{
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false,
})
} }
return request return request
} }
func embeddingVerifyURL(baseURL string) string {
lowerBaseURL := strings.ToLower(baseURL)
if strings.HasSuffix(lowerBaseURL, "/embeddings") {
return baseURL
}
if strings.HasSuffix(lowerBaseURL, "/v1") {
return baseURL + "/embeddings"
}
return baseURL + "/v1/embeddings"
}
func imageVerifyURL(provider, baseURL, endpoint string) string {
if provider == "custom" || strings.HasSuffix(strings.ToLower(baseURL), endpoint) {
return baseURL
}
return baseURL + endpoint
}
func verifyHTTPError(statusCode int, body []byte) string {
message := strings.TrimSpace(string(body))
var payload verifyErrorResponse
if err := json.Unmarshal(body, &payload); err == nil {
if value := strings.TrimSpace(payload.Error.Message); value != "" {
message = value
} else if value := strings.TrimSpace(payload.Message); value != "" {
message = value
}
}
if message == "" {
return fmt.Sprintf("validation request returned status %d", statusCode)
}
return message
}
func mustJSON(value interface{}) []byte { func mustJSON(value interface{}) []byte {
payload, err := json.Marshal(value) payload, err := json.Marshal(value)
if err != nil { if err != nil {
+3 -2
View File
@@ -17,7 +17,7 @@ type IAgentAccountRepo interface {
Save(account *model.AgentAccount) error Save(account *model.AgentAccount) error
DeleteByID(id uint) error DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error) List(opts ...DBOption) ([]model.AgentAccount, error)
CountByProviders(providers []string) (map[string]int64, error) CountTextByProviders(providers []string) (map[string]int64, error)
} }
func NewIAgentAccountRepo() IAgentAccountRepo { func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -67,7 +67,7 @@ func (a AgentAccountRepo) List(opts ...DBOption) ([]model.AgentAccount, error) {
return accounts, nil return accounts, nil
} }
func (a AgentAccountRepo) CountByProviders(providers []string) (map[string]int64, error) { func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]int64, error) {
normalizedProviders := normalizeProviders(providers) normalizedProviders := normalizeProviders(providers)
counts := make(map[string]int64, len(normalizedProviders)) counts := make(map[string]int64, len(normalizedProviders))
for _, provider := range normalizedProviders { for _, provider := range normalizedProviders {
@@ -86,6 +86,7 @@ func (a AgentAccountRepo) CountByProviders(providers []string) (map[string]int64
Model(&model.AgentAccount{}). Model(&model.AgentAccount{}).
Select("provider, COUNT(*) as count"). Select("provider, COUNT(*) as count").
Where("provider IN ?", normalizedProviders). Where("provider IN ?", normalizedProviders).
Scopes(WithTextAPIType()).
Group("provider"). Group("provider").
Scan(&rows).Error; err != nil { Scan(&rows).Error; err != nil {
return nil, err return nil, err
+217 -9
View File
@@ -1,20 +1,30 @@
package repo package repo
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt"
"strconv"
"strings" "strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"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/google/uuid"
"google.golang.org/genproto/googleapis/type/date" "google.golang.org/genproto/googleapis/type/date"
"gorm.io/gorm" "gorm.io/gorm"
"strconv" "gorm.io/gorm/clause"
"time"
) )
type AlertRepo struct{} type AlertRepo struct{}
var (
ErrAlertConfigRevisionConflict = errors.New("alert config revision conflict")
ErrAlertConfigRevisionRequired = errors.New("alert config revision is required")
)
type IAlertRepo interface { type IAlertRepo interface {
WithByType(alertType string) DBOption WithByType(alertType string) DBOption
WithByStatusIn(status []string) DBOption WithByStatusIn(status []string) DBOption
@@ -24,6 +34,7 @@ type IAlertRepo interface {
WithByCreateAt(date *date.Date) DBOption WithByCreateAt(date *date.Date) DBOption
WithByLicenseId(licenseId string) DBOption WithByLicenseId(licenseId string) DBOption
WithByRecordId(recordId uint) DBOption WithByRecordId(recordId uint) DBOption
WithByDeliveryLogID(logID uint) DBOption
WithByAlertMethodContainsConfigID(id uint) DBOption WithByAlertMethodContainsConfigID(id uint) DBOption
WithByMethodConfigIDs(ids []uint) DBOption WithByMethodConfigIDs(ids []uint) DBOption
@@ -45,6 +56,8 @@ type IAlertRepo interface {
CleanAlertLogs() error CleanAlertLogs() error
CreateAlertTask(alertTaskBase *model.AlertTask) error CreateAlertTask(alertTaskBase *model.AlertTask) error
CreatePendingAlertTask(logID, alertID uint, alertTask *model.AlertTask) (bool, error)
FinalizePendingAlertTask(logID uint, succeeded bool, message string, fallback *model.AlertTask) (bool, error)
DeleteAlertTask(opts ...DBOption) error DeleteAlertTask(opts ...DBOption) error
GetAlertTask(opts ...DBOption) (model.AlertTask, error) GetAlertTask(opts ...DBOption) (model.AlertTask, error)
LoadTaskCount(alertType string, project string, method string) (uint, uint, error) LoadTaskCount(alertType string, project string, method string) (uint, uint, error)
@@ -55,6 +68,7 @@ type IAlertRepo interface {
GetConfigById(id uint) (model.AlertConfig, error) GetConfigById(id uint) (model.AlertConfig, error)
AlertConfigList(opts ...DBOption) ([]model.AlertConfig, error) AlertConfigList(opts ...DBOption) ([]model.AlertConfig, error)
UpdateAlertConfig(maps map[string]interface{}, opts ...DBOption) error UpdateAlertConfig(maps map[string]interface{}, opts ...DBOption) error
UpdateAlertConfigWithRevision(maps map[string]interface{}, revision *time.Time, opts ...DBOption) error
CreateAlertConfig(config *model.AlertConfig) error CreateAlertConfig(config *model.AlertConfig) error
DeleteAlertConfig(opts ...DBOption) error DeleteAlertConfig(opts ...DBOption) error
@@ -223,13 +237,78 @@ func (a *AlertRepo) DeleteLog(opts ...DBOption) error {
} }
func (a *AlertRepo) CleanAlertLogs() error { func (a *AlertRepo) CleanAlertLogs() error {
return global.AlertDB.Where("1 = 1").Delete(&model.AlertLog{}).Error return global.AlertDB.Where("status <> ?", constant.AlertPushing).Delete(&model.AlertLog{}).Error
} }
func (a *AlertRepo) CreateAlertTask(alertTaskBase *model.AlertTask) error { func (a *AlertRepo) CreateAlertTask(alertTaskBase *model.AlertTask) error {
return global.AlertDB.Model(&model.AlertTask{}).Create(&alertTaskBase).Error return global.AlertDB.Model(&model.AlertTask{}).Create(&alertTaskBase).Error
} }
func (a *AlertRepo) CreatePendingAlertTask(logID, alertID uint, alertTask *model.AlertTask) (bool, error) {
if alertTask == nil {
return false, fmt.Errorf("pending alert task is required")
}
created := false
err := global.AlertDB.Transaction(func(tx *gorm.DB) error {
var log model.AlertLog
if err := tx.Where("id = ? AND status = ?", logID, constant.AlertPushing).First(&log).Error; err != nil {
return err
}
if log.AlertId != alertID || log.Type != alertTask.Type || log.Method != alertTask.Method {
return fmt.Errorf("pending alert task does not match delivery log %d", logID)
}
alertTask.DeliveryLogID = &logID
result := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "delivery_log_id"}},
DoNothing: true,
}).Create(alertTask)
if result.Error != nil {
return result.Error
}
created = result.RowsAffected > 0
return nil
})
return created, err
}
func (a *AlertRepo) FinalizePendingAlertTask(logID uint, succeeded bool, message string, fallback *model.AlertTask) (bool, error) {
finalized := false
err := global.AlertDB.Transaction(func(tx *gorm.DB) error {
status := constant.AlertError
if succeeded {
status = constant.AlertSuccess
message = ""
}
result := tx.Model(&model.AlertLog{}).
Where("id = ? AND status = ?", logID, constant.AlertPushing).
Updates(map[string]interface{}{"status": status, "message": message})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
finalized = true
if !succeeded {
return tx.Where("delivery_log_id = ?", logID).Delete(&model.AlertTask{}).Error
}
var count int64
if err := tx.Model(&model.AlertTask{}).Where("delivery_log_id = ?", logID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
if fallback == nil {
return fmt.Errorf("pending alert task metadata is unavailable for delivery log %d", logID)
}
fallback.DeliveryLogID = &logID
return tx.Create(fallback).Error
})
return finalized, err
}
func (a *AlertRepo) DeleteAlertTask(opts ...DBOption) error { func (a *AlertRepo) DeleteAlertTask(opts ...DBOption) error {
db, _ := getAlertDB(opts...) db, _ := getAlertDB(opts...)
return db.Delete(&model.AlertTask{}).Error return db.Delete(&model.AlertTask{}).Error
@@ -310,7 +389,23 @@ func (a *AlertRepo) UpdateAlertConfig(maps map[string]interface{}, opts ...DBOpt
return db.Model(&model.AlertConfig{}).Updates(maps).Error return db.Model(&model.AlertConfig{}).Updates(maps).Error
} }
func (a *AlertRepo) UpdateAlertConfigWithRevision(maps map[string]interface{}, revision *time.Time, opts ...DBOption) error {
if revision == nil {
return a.UpdateAlertConfig(maps, opts...)
}
db, _ := getAlertDB(opts...)
result := db.Model(&model.AlertConfig{}).Where("updated_at = ?", *revision).Updates(maps)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrAlertConfigRevisionConflict
}
return nil
}
func (a *AlertRepo) CreateAlertConfig(config *model.AlertConfig) error { func (a *AlertRepo) CreateAlertConfig(config *model.AlertConfig) error {
ensureAlertConfigUID(config)
return global.AlertDB.Model(&model.AlertConfig{}).Create(config).Error return global.AlertDB.Model(&model.AlertConfig{}).Create(config).Error
} }
@@ -338,6 +433,12 @@ func (a *AlertRepo) WithByTypeNotIn(types []string) DBOption {
} }
} }
func (a *AlertRepo) WithByDeliveryLogID(logID uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("delivery_log_id = ?", logID)
}
}
func (a *AlertRepo) PageAlertConfig(page, size int, opts ...DBOption) (int64, []model.AlertConfig, error) { func (a *AlertRepo) PageAlertConfig(page, size int, opts ...DBOption) (int64, []model.AlertConfig, error) {
var configs []model.AlertConfig var configs []model.AlertConfig
db := global.AlertDB.Model(&model.AlertConfig{}) db := global.AlertDB.Model(&model.AlertConfig{})
@@ -378,26 +479,44 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
return err return err
} }
oldConfigMap := make(map[string]uint) oldConfigMap := make(map[string]model.AlertConfig)
oldConfigByUID := make(map[string]model.AlertConfig)
oldConfigByType := make(map[string][]model.AlertConfig) oldConfigByType := make(map[string][]model.AlertConfig)
oldConfigByKey := make(map[string][]model.AlertConfig) oldConfigByKey := make(map[string][]model.AlertConfig)
consumedConfigIDs := make(map[uint]struct{}) consumedConfigIDs := make(map[uint]struct{})
for _, item := range oldConfigs { for _, item := range oldConfigs {
if strings.TrimSpace(item.UID) != "" {
oldConfigByUID[item.UID] = item
}
if singletonTypes[item.Type] { if singletonTypes[item.Type] {
oldConfigMap[item.Type] = item.ID oldConfigMap[item.Type] = item
continue continue
} }
oldConfigByType[item.Type] = append(oldConfigByType[item.Type], item) oldConfigByType[item.Type] = append(oldConfigByType[item.Type], item)
oldConfigByKey[alertConfigSyncKey(item)] = append(oldConfigByKey[alertConfigSyncKey(item)], item) oldConfigByKey[alertConfigSyncKey(item)] = append(oldConfigByKey[alertConfigSyncKey(item)], item)
} }
for _, item := range data { for _, item := range data {
if uid := strings.TrimSpace(item.UID); uid != "" {
if matched, ok := oldConfigByUID[uid]; ok && matched.Type != item.Type {
tx.Rollback()
return fmt.Errorf("alert config UID %q belongs to type %q, not %q", uid, matched.Type, item.Type)
}
}
if singletonTypes[item.Type] { if singletonTypes[item.Type] {
if val, ok := oldConfigMap[item.Type]; ok { if matched, ok := oldConfigMap[item.Type]; ok {
item.ID = val if err := inheritAlertConfigSyncState(&item, matched); err != nil {
tx.Rollback()
return err
}
delete(oldConfigMap, item.Type) delete(oldConfigMap, item.Type)
consumedConfigIDs[item.ID] = struct{}{} consumedConfigIDs[item.ID] = struct{}{}
} else { } else {
item.ID = 0 item.ID = 0
ensureAlertConfigUID(&item)
if err := validateAlertConfigSyncSecret(&item); err != nil {
tx.Rollback()
return err
}
} }
if item.ID == 0 { if item.ID == 0 {
if err := tx.Create(&item).Error; err != nil { if err := tx.Create(&item).Error; err != nil {
@@ -411,9 +530,31 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
continue continue
} }
if strings.TrimSpace(item.UID) != "" {
if matched, ok := oldConfigByUID[item.UID]; ok {
delete(oldConfigByUID, item.UID)
if err := inheritAlertConfigSyncState(&item, matched); err != nil {
tx.Rollback()
return err
}
consumedConfigIDs[item.ID] = struct{}{}
if err := tx.Save(&item).Error; err != nil {
tx.Rollback()
return err
}
deleteAlertConfigByID(oldConfigByType, matched.ID)
deleteAlertConfigByID(oldConfigByKey, matched.ID)
continue
}
}
key := alertConfigSyncKey(item) key := alertConfigSyncKey(item)
if matched, ok := popAlertConfigByKey(oldConfigByKey, key); ok { if matched, ok := popAlertConfigByKey(oldConfigByKey, key); ok {
item.ID = matched.ID delete(oldConfigByUID, matched.UID)
if err := inheritAlertConfigSyncState(&item, matched); err != nil {
tx.Rollback()
return err
}
consumedConfigIDs[item.ID] = struct{}{} consumedConfigIDs[item.ID] = struct{}{}
if err := tx.Save(&item).Error; err != nil { if err := tx.Save(&item).Error; err != nil {
tx.Rollback() tx.Rollback()
@@ -424,7 +565,12 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
} }
if matched, ok := popUnusedAlertConfigByType(oldConfigByType, usedConfigIDs, item.Type); ok { if matched, ok := popUnusedAlertConfigByType(oldConfigByType, usedConfigIDs, item.Type); ok {
item.ID = matched.ID delete(oldConfigByUID, matched.UID)
deleteAlertConfigByID(oldConfigByKey, matched.ID)
if err := inheritAlertConfigSyncState(&item, matched); err != nil {
tx.Rollback()
return err
}
consumedConfigIDs[item.ID] = struct{}{} consumedConfigIDs[item.ID] = struct{}{}
if err := tx.Save(&item).Error; err != nil { if err := tx.Save(&item).Error; err != nil {
tx.Rollback() tx.Rollback()
@@ -434,6 +580,11 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
} }
item.ID = 0 item.ID = 0
ensureAlertConfigUID(&item)
if err := validateAlertConfigSyncSecret(&item); err != nil {
tx.Rollback()
return err
}
if err := tx.Create(&item).Error; err != nil { if err := tx.Create(&item).Error; err != nil {
tx.Rollback() tx.Rollback()
return err return err
@@ -458,6 +609,63 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
return nil return nil
} }
func ensureAlertConfigUID(config *model.AlertConfig) {
if config != nil && strings.TrimSpace(config.UID) == "" {
config.UID = uuid.NewString()
}
}
func inheritAlertConfigSyncState(incoming *model.AlertConfig, existing model.AlertConfig) error {
if incoming.Type != existing.Type {
return fmt.Errorf("alert config UID %q belongs to type %q, not %q", incoming.UID, existing.Type, incoming.Type)
}
preserveExistingCustom := incoming.Type == constant.Custom &&
existing.Status == constant.AlertDisable &&
incoming.Title == existing.Title &&
incoming.Status == existing.Status &&
incoming.Config == existing.Config &&
(incoming.SecretConfig == "" || incoming.SecretConfig == existing.SecretConfig)
incoming.ID = existing.ID
if strings.TrimSpace(incoming.UID) == "" {
incoming.UID = existing.UID
}
if incoming.Type == constant.Custom && incoming.SecretConfig == "" {
incoming.SecretConfig = existing.SecretConfig
}
if preserveExistingCustom {
return nil
}
return validateAlertConfigSyncSecret(incoming)
}
func validateAlertConfigSyncSecret(incoming *model.AlertConfig) error {
if incoming.Type != constant.Custom {
incoming.SecretConfig = ""
return nil
}
if strings.TrimSpace(incoming.SecretConfig) == "" {
return fmt.Errorf("custom webhook sync secret is missing")
}
var version struct {
SchemaVersion int `json:"schemaVersion"`
}
if err := json.Unmarshal([]byte(incoming.Config), &version); err != nil || version.SchemaVersion != 1 {
return fmt.Errorf("custom webhook sync config must use schemaVersion 1")
}
secret := incoming.SecretConfig
for _, prefix := range []string{"core:v1:", "agent:v1:"} {
if !strings.HasPrefix(secret, prefix) {
continue
}
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, prefix))
if err != nil || len(ciphertext) < 32 || len(ciphertext)%16 != 0 {
return fmt.Errorf("custom webhook sync secret envelope is invalid")
}
return nil
}
return fmt.Errorf("custom webhook sync secret must use a versioned envelope")
}
func loadUsedAlertConfigIDs(tx *gorm.DB) (map[uint]struct{}, error) { func loadUsedAlertConfigIDs(tx *gorm.DB) (map[uint]struct{}, error) {
var alerts []model.Alert var alerts []model.Alert
if err := tx.Select("method").Find(&alerts).Error; err != nil { if err := tx.Select("method").Find(&alerts).Error; err != nil {
+7
View File
@@ -15,6 +15,7 @@ type IAppInstallResourceRpo interface {
WithAppInstallId(appInstallId uint) DBOption WithAppInstallId(appInstallId uint) DBOption
WithLinkId(linkId uint) DBOption WithLinkId(linkId uint) DBOption
WithResourceId(resourceId uint) DBOption WithResourceId(resourceId uint) DBOption
WithResourceIds(resourceIds []uint) DBOption
GetBy(opts ...DBOption) ([]model.AppInstallResource, error) GetBy(opts ...DBOption) ([]model.AppInstallResource, error)
GetFirst(opts ...DBOption) (model.AppInstallResource, error) GetFirst(opts ...DBOption) (model.AppInstallResource, error)
Create(ctx context.Context, resource *model.AppInstallResource) error Create(ctx context.Context, resource *model.AppInstallResource) error
@@ -44,6 +45,12 @@ func (a AppInstallResourceRpo) WithResourceId(resourceId uint) DBOption {
} }
} }
func (a AppInstallResourceRpo) WithResourceIds(resourceIds []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("resource_id IN ?", resourceIds)
}
}
func (a AppInstallResourceRpo) GetBy(opts ...DBOption) ([]model.AppInstallResource, error) { func (a AppInstallResourceRpo) GetBy(opts ...DBOption) ([]model.AppInstallResource, error) {
db := global.DB.Model(&model.AppInstallResource{}) db := global.DB.Model(&model.AppInstallResource{})
var resources []model.AppInstallResource var resources []model.AppInstallResource
+18
View File
@@ -49,6 +49,12 @@ func WithByName(name string) DBOption {
} }
} }
func WithByPath(path string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("path = ?", path)
}
}
func WithByAddr(addr string) DBOption { func WithByAddr(addr string) DBOption {
return func(g *gorm.DB) *gorm.DB { return func(g *gorm.DB) *gorm.DB {
return g.Where("addr = ?", addr) return g.Where("addr = ?", addr)
@@ -94,6 +100,18 @@ func WithByProvider(provider string) DBOption {
} }
} }
func WithByAPIType(apiType string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("api_type = ?", apiType)
}
}
func WithTextAPIType() DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("api_type NOT LIKE ? AND api_type <> ?", "%-images", "openai-embeddings")
}
}
func WithByModel(model string) DBOption { func WithByModel(model string) DBOption {
return func(g *gorm.DB) *gorm.DB { return func(g *gorm.DB) *gorm.DB {
if len(model) == 0 { if len(model) == 0 {
+121
View File
@@ -0,0 +1,121 @@
package repo
import (
"context"
"fmt"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm"
)
type DatabaseUserRepo struct{}
type IDatabaseUserRepo interface {
Get(opts ...DBOption) (model.DatabaseUser, error)
List(opts ...DBOption) ([]model.DatabaseUser, error)
Save(user *model.DatabaseUser) error
Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error
WithByDatabase(database string) DBOption
WithByUser(username, host string) DBOption
WithByUserList(users [][2]string) DBOption
}
func NewIDatabaseUserRepo() IDatabaseUserRepo {
return &DatabaseUserRepo{}
}
func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
var user model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{})
for _, opt := range opts {
db = opt(db)
}
if err := db.First(&user).Error; err != nil {
return user, err
}
password, err := encrypt.StringDecrypt(user.Password)
if err != nil {
global.LOG.Errorf("decrypt database user %s password failed, err: %v", user.Username, err)
}
user.Password = password
return user, nil
}
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
var users []model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{})
for _, opt := range opts {
db = opt(db)
}
if err := db.Find(&users).Error; err != nil {
return users, err
}
for i := 0; i < len(users); i++ {
password, err := encrypt.StringDecrypt(users[i].Password)
if err != nil {
global.LOG.Errorf("decrypt database user %s password failed, err: %v", users[i].Username, err)
}
users[i].Password = password
}
return users, nil
}
func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
if len(user.Password) != 0 {
password, err := encrypt.StringEncrypt(user.Password)
if err != nil {
return fmt.Errorf("encrypt database user %s password failed, err: %v", user.Username, err)
}
user.Password = password
}
return global.DB.Save(user).Error
}
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.DatabaseUser{}).Error
}
func (u *DatabaseUserRepo) DeleteBy(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseUser{}).Error
}
func (u *DatabaseUserRepo) Update(vars map[string]interface{}, opts ...DBOption) error {
db := global.DB.Model(&model.DatabaseUser{})
for _, opt := range opts {
db = opt(db)
}
return db.Updates(vars).Error
}
func (u *DatabaseUserRepo) WithByDatabase(database string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("database = ?", database)
}
}
func (u *DatabaseUserRepo) WithByUser(username, host string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("username = ? AND host = ?", username, host)
}
}
func (u *DatabaseUserRepo) WithByUserList(users [][2]string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(users) == 0 {
return g.Where("1 = 0")
}
values := make([][]interface{}, 0, len(users))
for _, user := range users {
values = append(values, []interface{}{user[0], user[1]})
}
return g.Where("(username, host) IN ?", values)
}
}
+109
View File
@@ -0,0 +1,109 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type DatabaseUserGrantRepo struct{}
type IDatabaseUserGrantRepo interface {
Get(opts ...DBOption) (model.DatabaseUserGrant, error)
List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
Save(grant *model.DatabaseUserGrant) error
Replace(dbType, database string, grants []model.DatabaseUserGrant) error
Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error
WithByDatabase(database string) DBOption
WithByDBName(dbName string) DBOption
WithByDBNames(dbNames []string) DBOption
WithByUser(username, host string) DBOption
}
func (u *DatabaseUserGrantRepo) Get(opts ...DBOption) (model.DatabaseUserGrant, error) {
var grant model.DatabaseUserGrant
db := global.DB.Model(&model.DatabaseUserGrant{})
for _, opt := range opts {
db = opt(db)
}
err := db.First(&grant).Error
return grant, err
}
func NewIDatabaseUserGrantRepo() IDatabaseUserGrantRepo {
return &DatabaseUserGrantRepo{}
}
func (u *DatabaseUserGrantRepo) List(opts ...DBOption) ([]model.DatabaseUserGrant, error) {
var grants []model.DatabaseUserGrant
db := global.DB.Model(&model.DatabaseUserGrant{})
for _, opt := range opts {
db = opt(db)
}
err := db.Find(&grants).Error
return grants, err
}
func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
return global.DB.Save(grant).Error
}
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
return err
}
if len(grants) != 0 {
return tx.Create(&grants).Error
}
return nil
})
}
func (u *DatabaseUserGrantRepo) Delete(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.DatabaseUserGrant{}).Error
}
func (u *DatabaseUserGrantRepo) DeleteBy(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseUserGrant{}).Error
}
func (u *DatabaseUserGrantRepo) Update(vars map[string]interface{}, opts ...DBOption) error {
db := global.DB.Model(&model.DatabaseUserGrant{})
for _, opt := range opts {
db = opt(db)
}
return db.Updates(vars).Error
}
func (u *DatabaseUserGrantRepo) WithByDatabase(database string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("database = ?", database)
}
}
func (u *DatabaseUserGrantRepo) WithByDBName(dbName string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("db_name = ?", dbName)
}
}
func (u *DatabaseUserGrantRepo) WithByDBNames(dbNames []string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("db_name IN ?", dbNames)
}
}
func (u *DatabaseUserGrantRepo) WithByUser(username, host string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("username = ? AND host = ?", username, host)
}
}
+77
View File
@@ -0,0 +1,77 @@
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
@@ -0,0 +1,144 @@
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
@@ -0,0 +1,36 @@
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
})
}
-72
View File
@@ -22,11 +22,6 @@ type IHostRepo interface {
WithByPort(port uint) DBOption WithByPort(port uint) DBOption
WithByUser(user string) DBOption WithByUser(user string) DBOption
GetFirewallRecord(opts ...DBOption) (model.Firewall, error)
ListFirewallRecord(opts ...DBOption) ([]model.Firewall, error)
SaveFirewallRecord(firewall *model.Firewall) error
DeleteFirewallRecordByID(id uint) error
SyncCert(data []model.RootCert) error SyncCert(data []model.RootCert) error
GetCert(opts ...DBOption) (model.RootCert, error) GetCert(opts ...DBOption) (model.RootCert, error)
PageCert(limit, offset int, opts ...DBOption) (int64, []model.RootCert, error) PageCert(limit, offset int, opts ...DBOption) (int64, []model.RootCert, error)
@@ -34,8 +29,6 @@ type IHostRepo interface {
SaveCert(cert *model.RootCert) error SaveCert(cert *model.RootCert) error
UpdateCert(id uint, vars map[string]interface{}) error UpdateCert(id uint, vars map[string]interface{}) error
DeleteCert(opts ...DBOption) error DeleteCert(opts ...DBOption) error
WithByChain(chain string) DBOption
} }
func NewIHostRepo() IHostRepo { func NewIHostRepo() IHostRepo {
@@ -116,65 +109,6 @@ func (h *HostRepo) Delete(opts ...DBOption) error {
return db.Delete(&model.Host{}).Error return db.Delete(&model.Host{}).Error
} }
func (h *HostRepo) GetFirewallRecord(opts ...DBOption) (model.Firewall, error) {
var firewall model.Firewall
db := global.DB
for _, opt := range opts {
db = opt(db)
}
err := db.First(&firewall).Error
return firewall, err
}
func (h *HostRepo) ListFirewallRecord(opts ...DBOption) ([]model.Firewall, error) {
var firewalls []model.Firewall
db := global.DB
for _, opt := range opts {
db = opt(db)
}
if err := global.DB.Find(&firewalls).Error; err != nil {
return firewalls, nil
}
return firewalls, nil
}
func (h *HostRepo) SaveFirewallRecord(firewall *model.Firewall) error {
if firewall.ID != 0 {
return global.DB.Save(firewall).Error
}
var data model.Firewall
switch firewall.Type {
case "port":
_ = global.DB.Where("type = ? AND dst_port = ? AND protocol = ? AND src_ip = ? AND strategy = ?", "port",
firewall.DstPort,
firewall.Protocol,
firewall.SrcIP,
firewall.Strategy,
).First(&data).Error
case "ip":
_ = global.DB.Where("type = ? AND src_ip = ? AND strategy = ?", "address", firewall.SrcIP, firewall.Strategy).First(&data)
default:
_ = global.DB.Where("type = ? AND chain = ? AND src_port = ? AND dst_port = ? AND protocol = ? AND src_ip = ? AND dst_ip = ? AND strategy = ?",
firewall.Type,
firewall.Chain,
firewall.SrcPort,
firewall.DstPort,
firewall.Protocol,
firewall.SrcIP,
firewall.DstIP,
firewall.Strategy,
).First(&data).Error
}
if data.ID != 0 {
firewall.ID = data.ID
}
return global.DB.Save(firewall).Error
}
func (h *HostRepo) DeleteFirewallRecordByID(id uint) error {
return global.DB.Where("id = ?", id).Delete(&model.Firewall{}).Error
}
func (u *HostRepo) GetCert(opts ...DBOption) (model.RootCert, error) { func (u *HostRepo) GetCert(opts ...DBOption) (model.RootCert, error) {
var cert model.RootCert var cert model.RootCert
db := global.DB db := global.DB
@@ -253,9 +187,3 @@ func (u *HostRepo) SyncCert(data []model.RootCert) error {
tx.Commit() tx.Commit()
return nil return nil
} }
func (u *HostRepo) WithByChain(chain string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("chain = ?", chain)
}
}
+3
View File
@@ -73,6 +73,9 @@ func (u *MonitorRepo) CreateMonitorBase(model model.MonitorBase) error {
return global.MonitorDB.Create(&model).Error return global.MonitorDB.Create(&model).Error
} }
func (s *MonitorRepo) BatchCreateMonitorGPU(list []model.MonitorGPU) error { func (s *MonitorRepo) BatchCreateMonitorGPU(list []model.MonitorGPU) error {
if len(list) == 0 {
return nil
}
return global.GPUMonitorDB.CreateInBatches(&list, len(list)).Error return global.GPUMonitorDB.CreateInBatches(&list, len(list)).Error
} }
func (u *MonitorRepo) BatchCreateMonitorIO(ioList []model.MonitorIO) error { func (u *MonitorRepo) BatchCreateMonitorIO(ioList []model.MonitorIO) error {
+21
View File
@@ -17,10 +17,12 @@ type IWebsiteRepo interface {
WithGroupID(groupId uint) DBOption WithGroupID(groupId uint) DBOption
WithDefaultServer() DBOption WithDefaultServer() DBOption
WithDomainLike(domain string) DBOption WithDomainLike(domain string) DBOption
WithSearchKeyword(keyword string, ids []uint) DBOption
WithRuntimeID(runtimeID uint) DBOption WithRuntimeID(runtimeID uint) DBOption
WithParentID(websiteID uint) DBOption WithParentID(websiteID uint) DBOption
WithType(websiteType string) DBOption WithType(websiteType string) DBOption
WithDBType(dbType string) DBOption WithDBType(dbType string) DBOption
WithDBTypes(dbTypes []string) DBOption
WithDBID(dbID uint) DBOption WithDBID(dbID uint) DBOption
Page(page, size int, opts ...DBOption) (int64, []model.Website, error) Page(page, size int, opts ...DBOption) (int64, []model.Website, error)
@@ -76,6 +78,19 @@ func (w *WebsiteRepo) WithDomainLike(domain string) DBOption {
} }
} }
func (w *WebsiteRepo) WithSearchKeyword(keyword string, ids []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
if keyword == "" {
return db
}
keyword = "%" + keyword + "%"
if len(ids) == 0 {
return db.Where("(primary_domain like ? OR alias like ?)", keyword, keyword)
}
return db.Where("(primary_domain like ? OR alias like ? OR id in (?))", keyword, keyword, ids)
}
}
func (w *WebsiteRepo) WithAlias(alias string) DBOption { func (w *WebsiteRepo) WithAlias(alias string) DBOption {
return func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB {
return db.Where("alias = ?", alias) return db.Where("alias = ?", alias)
@@ -118,6 +133,12 @@ func (w *WebsiteRepo) WithDBType(dbType string) DBOption {
} }
} }
func (w *WebsiteRepo) WithDBTypes(dbTypes []string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("db_type IN ?", dbTypes)
}
}
func (w *WebsiteRepo) WithDBID(dbID uint) DBOption { func (w *WebsiteRepo) WithDBID(dbID uint) DBOption {
return func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB {
return db.Where("db_id = ?", dbID) return db.Where("db_id = ?", dbID)
+131
View File
@@ -0,0 +1,131 @@
package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"gorm.io/gorm"
)
type IWebsiteTemplateRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error)
GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error)
List(opts ...DBOption) ([]model.WebsiteTemplate, error)
Create(template *model.WebsiteTemplate) error
Save(template *model.WebsiteTemplate) error
DeleteBy(opts ...DBOption) error
WithName(name string) DBOption
WithType(templateType string) DBOption
}
func NewIWebsiteTemplateRepo() IWebsiteTemplateRepo {
return &WebsiteTemplateRepo{}
}
type WebsiteTemplateRepo struct {
}
func (w *WebsiteTemplateRepo) WithName(name string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("name like ?", "%"+name+"%")
}
}
func (w *WebsiteTemplateRepo) WithType(templateType string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("type = ?", templateType)
}
}
func (w *WebsiteTemplateRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error) {
var templates []model.WebsiteTemplate
db := getDb(opts...).Model(&model.WebsiteTemplate{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&templates).Error
return count, templates, err
}
func (w *WebsiteTemplateRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error) {
var template model.WebsiteTemplate
db := getDb(opts...).Model(&model.WebsiteTemplate{})
if err := db.First(&template).Error; err != nil {
return nil, err
}
return &template, nil
}
func (w *WebsiteTemplateRepo) List(opts ...DBOption) ([]model.WebsiteTemplate, error) {
var templates []model.WebsiteTemplate
err := getDb(opts...).Model(&model.WebsiteTemplate{}).Find(&templates).Error
return templates, err
}
func (w *WebsiteTemplateRepo) Create(template *model.WebsiteTemplate) error {
return getDb().Create(template).Error
}
func (w *WebsiteTemplateRepo) Save(template *model.WebsiteTemplate) error {
return getDb().Save(template).Error
}
func (w *WebsiteTemplateRepo) DeleteBy(opts ...DBOption) error {
return getDb(opts...).Delete(&model.WebsiteTemplate{}).Error
}
type IWebsiteTemplateOutputRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error)
GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error)
List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error)
Create(output *model.WebsiteTemplateOutput) error
Save(output *model.WebsiteTemplateOutput) error
DeleteBy(opts ...DBOption) error
WithByTemplateID(templateID uint) DBOption
}
func NewIWebsiteTemplateOutputRepo() IWebsiteTemplateOutputRepo {
return &WebsiteTemplateOutputRepo{}
}
type WebsiteTemplateOutputRepo struct {
}
func (w *WebsiteTemplateOutputRepo) WithByTemplateID(templateID uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("template_id = ?", templateID)
}
}
func (w *WebsiteTemplateOutputRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error) {
var outputs []model.WebsiteTemplateOutput
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&outputs).Error
return count, outputs, err
}
func (w *WebsiteTemplateOutputRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error) {
var output model.WebsiteTemplateOutput
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
if err := db.First(&output).Error; err != nil {
return nil, err
}
return &output, nil
}
func (w *WebsiteTemplateOutputRepo) List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error) {
var outputs []model.WebsiteTemplateOutput
err := getDb(opts...).Model(&model.WebsiteTemplateOutput{}).Find(&outputs).Error
return outputs, err
}
func (w *WebsiteTemplateOutputRepo) Create(output *model.WebsiteTemplateOutput) error {
return getDb().Create(output).Error
}
func (w *WebsiteTemplateOutputRepo) Save(output *model.WebsiteTemplateOutput) error {
return getDb().Save(output).Error
}
func (w *WebsiteTemplateOutputRepo) DeleteBy(opts ...DBOption) error {
return getDb(opts...).Delete(&model.WebsiteTemplateOutput{}).Error
}
+281 -141
View File
@@ -8,8 +8,8 @@ import (
"os" "os"
"path" "path"
"sort" "sort"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -30,6 +30,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
var agentAccountMutationMu sync.Mutex
type IAgentService interface { type IAgentService interface {
Create(req dto.AgentCreateReq) (*dto.AgentItem, error) Create(req dto.AgentCreateReq) (*dto.AgentItem, error)
BatchInstall(req dto.AgentBatchInstallReq) (*dto.AgentItem, error) BatchInstall(req dto.AgentBatchInstallReq) (*dto.AgentItem, error)
@@ -77,6 +79,7 @@ type IAgentService interface {
PageAccounts(req dto.AgentAccountSearch) (int64, []dto.AgentAccountInfo, error) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.AgentAccountInfo, error)
CountAccountsByProviders(req dto.AgentAccountProviderCountReq) (map[string]int64, error) CountAccountsByProviders(req dto.AgentAccountProviderCountReq) (map[string]int64, error)
GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error)
DiscoverAccountModels(req dto.AgentAccountModelDiscoverReq) ([]dto.AgentAccountModel, error)
CreateAccountModel(req dto.AgentAccountModelCreateReq) error CreateAccountModel(req dto.AgentAccountModelCreateReq) error
UpdateAccountModel(req dto.AgentAccountModelUpdateReq) error UpdateAccountModel(req dto.AgentAccountModelUpdateReq) error
DeleteAccountModel(req dto.AgentAccountModelDeleteReq) error DeleteAccountModel(req dto.AgentAccountModelDeleteReq) error
@@ -102,6 +105,10 @@ type IAgentService interface {
UpgradePlugin(req dto.AgentPluginUpgradeReq) error UpgradePlugin(req dto.AgentPluginUpgradeReq) error
UninstallPlugin(req dto.AgentPluginUninstallReq) error UninstallPlugin(req dto.AgentPluginUninstallReq) error
CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error)
ListPlugins(req dto.AgentPluginsReq) ([]dto.AgentPluginItem, error)
SearchPlugins(req dto.AgentPluginSearchReq) ([]dto.AgentPluginSearchItem, error)
InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) error
OperatePlugin(req dto.AgentPluginOperateReq) error
ApproveChannelPairing(req dto.AgentChannelPairingApproveReq) error ApproveChannelPairing(req dto.AgentChannelPairingApproveReq) error
} }
@@ -144,7 +151,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
if installs, _ := appInstallRepo.ListBy(context.Background(), repo.WithByLowerName(req.Name)); len(installs) > 0 { if installs, _ := appInstallRepo.ListBy(context.Background(), repo.WithByLowerName(req.Name)); len(installs) > 0 {
return nil, buserr.New("ErrNameIsExist") return nil, buserr.New("ErrNameIsExist")
} }
if !xpack.MultiNodeProvider.IsXpack() { if !global.CONF.Base.IsEnterprise && !xpack.MultiNodeProvider.IsXpack() {
count, _, err := agentRepo.Page(1, 1) count, _, err := agentRepo.Page(1, 1)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -165,8 +172,6 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
provider := "" provider := ""
baseURL := "" baseURL := ""
apiType := "" apiType := ""
maxTokens := 0
contextWindow := 0
apiKey := "" apiKey := ""
runtimeModel := "" runtimeModel := ""
accountID := uint(0) accountID := uint(0)
@@ -176,6 +181,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
var allowedOrigins []string var allowedOrigins []string
var account *model.AgentAccount var account *model.AgentAccount
var installHooks *appInstallHooks var installHooks *appInstallHooks
var dashboardAuth agentDashboardAuth
if agentType == constant.AppOpenclaw || agentType == constant.AppHermesAgent { if agentType == constant.AppOpenclaw || agentType == constant.AppHermesAgent {
if req.AccountID == 0 { if req.AccountID == 0 {
@@ -196,8 +202,6 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
} }
storedModel = resolvedRuntime.StoredModel storedModel = resolvedRuntime.StoredModel
apiType = resolvedRuntime.APIType apiType = resolvedRuntime.APIType
maxTokens = resolvedRuntime.MaxTokens
contextWindow = resolvedRuntime.ContextWindow
runtimeModel = resolvedRuntime.PrimaryModel runtimeModel = resolvedRuntime.PrimaryModel
apiKey = account.APIKey apiKey = account.APIKey
accountID = account.ID accountID = account.ID
@@ -222,11 +226,17 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
}, },
} }
} else if agentType == constant.AppHermesAgent { } else if agentType == constant.AppHermesAgent {
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
installHooks = &appInstallHooks{ installHooks = &appInstallHooks{
AfterCopyData: func(appInstall *model.AppInstall) error { AfterCopyData: func(appInstall *model.AppInstall) error {
return prepareHermesInstallFiles(appInstall, account, storedModel) if err := prepareHermesInstallFiles(appInstall, account, storedModel); err != nil {
return err
}
return writeAgentDashboardAuthEnv(appInstall.GetEnvPath(), agentType, dashboardAuth, false)
}, },
} }
} else if agentType == constant.AppCopaw {
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
} }
params := map[string]interface{}{ params := map[string]interface{}{
@@ -242,12 +252,17 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
params["PROVIDER"] = provider params["PROVIDER"] = provider
params["MODEL"] = runtimeModel params["MODEL"] = runtimeModel
params["API_TYPE"] = apiType params["API_TYPE"] = apiType
params["MAX_TOKENS"] = maxTokens
params["CONTEXT_WINDOW"] = contextWindow
params["BASE_URL"] = baseURL params["BASE_URL"] = baseURL
params["API_KEY"] = apiKey params["API_KEY"] = apiKey
params["OPENCLAW_GATEWAY_TOKEN"] = token params["OPENCLAW_GATEWAY_TOKEN"] = token
} }
if usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType); ok {
params[usernameKey] = dashboardAuth.Username
params[passwordKey] = dashboardAuth.Password
if agentType == constant.AppCopaw {
params[qwenPawAuthEnabledEnvKey] = "true"
}
}
if req.EditCompose && strings.TrimSpace(req.DockerCompose) == "" { if req.EditCompose && strings.TrimSpace(req.DockerCompose) == "" {
return nil, buserr.New("ErrAgentComposeRequired") return nil, buserr.New("ErrAgentComposeRequired")
@@ -282,22 +297,20 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
configPath = path.Join(appInstall.GetPath(), "data", "config.yaml") configPath = path.Join(appInstall.GetPath(), "data", "config.yaml")
} }
agent := &model.Agent{ agent := &model.Agent{
Name: req.Name, Name: req.Name,
Remark: req.Remark, Remark: req.Remark,
AgentType: agentType, AgentType: agentType,
Provider: provider, Provider: provider,
Model: storedModel, Model: storedModel,
APIType: apiType, APIType: apiType,
MaxTokens: maxTokens, BaseURL: baseURL,
ContextWindow: contextWindow, APIKey: apiKey,
BaseURL: baseURL, Token: token,
APIKey: apiKey, Status: appInstall.Status,
Token: token, Message: appInstall.Message,
Status: appInstall.Status, AppInstallID: appInstall.ID,
Message: appInstall.Message, AccountID: accountID,
AppInstallID: appInstall.ID, ConfigPath: configPath,
AccountID: accountID,
ConfigPath: configPath,
} }
if err := agentRepo.Create(agent); err != nil { if err := agentRepo.Create(agent); err != nil {
return nil, err return nil, err
@@ -395,7 +408,7 @@ func (a AgentService) BatchInstallSkill(req dto.AgentBatchSkillInstallReq) ([]dt
results = append(results, result) results = append(results, result)
continue continue
} }
installTask, err := task.NewTaskWithOps(skillName, task.TaskInstall, task.TaskScopeAI, buildBatchSkillInstallTaskID(req.TaskID, agent.ID), agent.ID) installTask, err := task.NewTaskWithOps(skillName, task.TaskInstall, task.TaskScopeAI, buildBatchTaskID(req.TaskID, "batch-skill-install", agent.ID), agent.ID)
if err != nil { if err != nil {
result.Message = err.Error() result.Message = err.Error()
results = append(results, result) results = append(results, result)
@@ -437,7 +450,7 @@ func (a AgentService) BatchOperate(req dto.AgentBatchOperateReq) ([]dto.AgentBat
if operate == constant.Delete { if operate == constant.Delete {
if err := a.Delete(dto.AgentDeleteReq{ if err := a.Delete(dto.AgentDeleteReq{
ID: agent.ID, ID: agent.ID,
TaskID: buildBatchOperateTaskID(req.TaskID, agent.ID), TaskID: buildBatchTaskID(req.TaskID, "batch-operate", agent.ID),
ForceDelete: req.ForceDelete, ForceDelete: req.ForceDelete,
}); err != nil { }); err != nil {
result.Message = err.Error() result.Message = err.Error()
@@ -465,7 +478,7 @@ func (a AgentService) BatchOperate(req dto.AgentBatchOperateReq) ([]dto.AgentBat
if err := NewIAppInstalledService().Operate(request.AppInstalledOperate{ if err := NewIAppInstalledService().Operate(request.AppInstalledOperate{
InstallId: install.ID, InstallId: install.ID,
Operate: operate, Operate: operate,
TaskID: buildBatchOperateTaskID(req.TaskID, agent.ID), TaskID: buildBatchTaskID(req.TaskID, "batch-operate", agent.ID),
}); err != nil { }); err != nil {
result.Message = err.Error() result.Message = err.Error()
} else { } else {
@@ -549,35 +562,19 @@ func buildBatchUpgradePlans(req dto.AgentBatchUpgradeReq) ([]batchUpgradePlan, [
DetailID: detail.ID, DetailID: detail.ID,
Backup: req.Backup, Backup: req.Backup,
PullImage: req.PullImage, PullImage: req.PullImage,
TaskID: buildBatchUpgradeTaskID(req.TaskID, install.ID), TaskID: buildBatchTaskID(req.TaskID, "batch-upgrade", install.ID),
}, },
}) })
} }
return plans, results, nil return plans, results, nil
} }
func buildBatchUpgradeTaskID(taskID string, appInstallID uint) string { func buildBatchTaskID(taskID, prefix string, id uint) string {
taskID = strings.TrimSpace(taskID) taskID = strings.TrimSpace(taskID)
if taskID == "" { if taskID == "" {
taskID = fmt.Sprintf("batch-upgrade-%d-%d", appInstallID, time.Now().UnixNano()) taskID = fmt.Sprintf("%s-%d-%d", prefix, id, time.Now().UnixNano())
} }
return fmt.Sprintf("%s-%d", taskID, appInstallID) return fmt.Sprintf("%s-%d", taskID, id)
}
func buildBatchSkillInstallTaskID(taskID string, agentID uint) string {
taskID = strings.TrimSpace(taskID)
if taskID == "" {
taskID = fmt.Sprintf("batch-skill-install-%d-%d", agentID, time.Now().UnixNano())
}
return fmt.Sprintf("%s-%d", taskID, agentID)
}
func buildBatchOperateTaskID(taskID string, agentID uint) string {
taskID = strings.TrimSpace(taskID)
if taskID == "" {
taskID = fmt.Sprintf("batch-operate-%d-%d", agentID, time.Now().UnixNano())
}
return fmt.Sprintf("%s-%d", taskID, agentID)
} }
func batchOperateSkipMessage(operate constant.AppOperate, status string) string { func batchOperateSkipMessage(operate constant.AppOperate, status string) string {
@@ -604,28 +601,30 @@ func batchOperateSkipMessage(operate constant.AppOperate, status string) string
func buildCreateReqFromBatchInstallReq(req dto.AgentBatchInstallReq) dto.AgentCreateReq { func buildCreateReqFromBatchInstallReq(req dto.AgentBatchInstallReq) dto.AgentCreateReq {
return dto.AgentCreateReq{ return dto.AgentCreateReq{
Name: req.Name, Name: req.Name,
Remark: req.Remark, Remark: req.Remark,
AppVersion: req.AppVersion, AppVersion: req.AppVersion,
WebUIPort: req.WebUIPort, WebUIPort: req.WebUIPort,
BridgePort: req.BridgePort, BridgePort: req.BridgePort,
AllowedOrigins: req.AllowedOrigins, AllowedOrigins: req.AllowedOrigins,
AgentType: req.AgentType, AgentType: req.AgentType,
Model: req.Model, Model: req.Model,
AccountID: req.AccountID, AccountID: req.AccountID,
Token: req.Token, Token: req.Token,
TaskID: req.TaskID, DashboardUsername: req.DashboardUsername,
Advanced: req.Advanced, DashboardPassword: req.DashboardPassword,
ContainerName: req.ContainerName, TaskID: req.TaskID,
AllowPort: req.AllowPort, Advanced: req.Advanced,
SpecifyIP: req.SpecifyIP, ContainerName: req.ContainerName,
RestartPolicy: req.RestartPolicy, AllowPort: req.AllowPort,
CpuQuota: req.CpuQuota, SpecifyIP: req.SpecifyIP,
MemoryLimit: req.MemoryLimit, RestartPolicy: req.RestartPolicy,
MemoryUnit: req.MemoryUnit, CpuQuota: req.CpuQuota,
PullImage: req.PullImage, MemoryLimit: req.MemoryLimit,
EditCompose: req.EditCompose, MemoryUnit: req.MemoryUnit,
DockerCompose: req.DockerCompose, PullImage: req.PullImage,
EditCompose: req.EditCompose,
DockerCompose: req.DockerCompose,
} }
} }
@@ -704,6 +703,7 @@ func (a AgentService) ensureBatchInstallAccount(req dto.AgentBatchInstallReq) (u
account.RememberAPIKey = snapshot.RememberAPIKey account.RememberAPIKey = snapshot.RememberAPIKey
account.BaseURL = snapshot.BaseURL account.BaseURL = snapshot.BaseURL
account.APIType = snapshot.APIType account.APIType = snapshot.APIType
account.AuthMode = snapshot.AuthMode
account.Remark = snapshot.Remark account.Remark = snapshot.Remark
account.Verified = true account.Verified = true
@@ -711,6 +711,11 @@ func (a AgentService) ensureBatchInstallAccount(req dto.AgentBatchInstallReq) (u
if err != nil { if err != nil {
return 0, err return 0, err
} }
verifyModel, err := resolveAgentAccountVerifyModel(account.Provider, snapshot.VerifyModel, initialModels)
if err != nil {
return 0, err
}
account.VerifyModel = verifyModel
if err := global.DB.Transaction(func(tx *gorm.DB) error { if err := global.DB.Transaction(func(tx *gorm.DB) error {
if account.ID == 0 { if account.ID == 0 {
if err := tx.Create(account).Error; err != nil { if err := tx.Create(account).Error; err != nil {
@@ -931,6 +936,7 @@ func (a AgentService) GetModelConfig(req dto.AgentIDReq) (*dto.AgentModelConfig,
AccountID: agent.AccountID, AccountID: agent.AccountID,
Model: model, Model: model,
Fallbacks: extractOpenclawFallbackModelIDs(conf, account, models, model), Fallbacks: extractOpenclawFallbackModelIDs(conf, account, models, model),
Metadata: extractOpenclawModelMetadata(conf, account, models),
}, nil }, nil
} }
@@ -948,7 +954,7 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
return err return err
} }
modelName := resolvedRuntime.StoredModel modelName := resolvedRuntime.StoredModel
apiType, maxTokens, contextWindow := resolvedRuntime.APIType, resolvedRuntime.MaxTokens, resolvedRuntime.ContextWindow apiType := resolvedRuntime.APIType
confDir := path.Dir(agent.ConfigPath) confDir := path.Dir(agent.ConfigPath)
if agent.AgentType == constant.AppHermesAgent { if agent.AgentType == constant.AppHermesAgent {
cfg, err := readHermesConfig(agent.ConfigPath) cfg, err := readHermesConfig(agent.ConfigPath)
@@ -962,15 +968,13 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
if agent.AgentType != constant.AppOpenclaw { if agent.AgentType != constant.AppOpenclaw {
return fmt.Errorf("%s does not support", agent.AgentType) return fmt.Errorf("%s does not support", agent.AgentType)
} }
if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil, req.Fallbacks); err != nil { if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil, req.Fallbacks, req.Metadata); err != nil {
return err return err
} }
} }
agent.Provider = account.Provider agent.Provider = account.Provider
agent.Model = modelName agent.Model = modelName
agent.APIType = apiType agent.APIType = apiType
agent.MaxTokens = maxTokens
agent.ContextWindow = contextWindow
agent.BaseURL = account.BaseURL agent.BaseURL = account.BaseURL
agent.APIKey = account.APIKey agent.APIKey = account.APIKey
agent.AccountID = account.ID agent.AccountID = account.ID
@@ -984,20 +988,38 @@ func (a AgentService) GetProviders() ([]dto.ProviderInfo, error) {
models := make([]dto.ProviderModelInfo, 0, len(def.Models)) models := make([]dto.ProviderModelInfo, 0, len(def.Models))
for _, item := range def.Models { for _, item := range def.Models {
models = append(models, dto.ProviderModelInfo{ models = append(models, dto.ProviderModelInfo{
ID: item.ID, ID: item.ID,
Name: item.Name, Name: item.Name,
ContextWindow: item.ContextWindow,
MaxTokens: item.MaxTokens,
Reasoning: item.Reasoning,
Input: append([]string(nil), item.Input...),
}) })
} }
apiTypes := make([]dto.ProviderAPIInfo, 0, len(def.APIConfigs))
for _, item := range def.APIConfigs {
apiModels := make([]dto.ProviderModelInfo, 0, len(item.Models))
for _, model := range item.Models {
apiModels = append(apiModels, dto.ProviderModelInfo{
ID: model.ID,
Name: model.Name,
})
}
apiTypes = append(apiTypes, dto.ProviderAPIInfo{
APIType: item.APIType,
BaseURL: item.BaseURL,
EditableBaseURL: item.EditableBaseURL,
SupportsModelDiscovery: item.DiscoverModels,
DefaultAuthMode: item.DefaultAuthMode,
AuthModes: item.AuthModes,
Models: apiModels,
})
}
baseURL, _ := providercatalog.DefaultBaseURL(key)
providers = append(providers, dto.ProviderInfo{ providers = append(providers, dto.ProviderInfo{
Sort: def.Sort, Sort: def.Sort,
Provider: key, Provider: key,
DisplayName: def.DisplayName, DisplayName: localizedAgentProviderName(key),
BaseURL: def.DefaultBaseURL, BaseURL: baseURL,
Models: models, DefaultAPIType: def.DefaultAPIType,
APITypes: apiTypes,
Models: models,
}) })
} }
sort.Slice(providers, func(i, j int) bool { sort.Slice(providers, func(i, j int) bool {
@@ -1007,11 +1029,22 @@ func (a AgentService) GetProviders() ([]dto.ProviderInfo, error) {
} }
func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error { func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
provider := req.Provider provider := req.Provider
if exist, _ := agentAccountRepo.GetFirst(repo.WithByProvider(provider), repo.WithByName(req.Name)); exist != nil && exist.ID > 0 { if err := ensureAgentAccountNameAvailable(provider, req.Name, 0); err != nil {
return buserr.New("ErrRecordExist") return err
} }
resolvedInput, err := resolveAgentAccountInput(provider, req.APIKey, req.BaseURL) initialModels, err := buildInitialAgentAccountModels(&model.AgentAccount{Provider: provider, APIType: req.APIType}, req.Models)
if err != nil {
return err
}
verifyModel, err := resolveAgentAccountVerifyModel(provider, req.VerifyModel, initialModels)
if err != nil {
return err
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
if err != nil { if err != nil {
return err return err
} }
@@ -1021,14 +1054,12 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
APIKey: resolvedInput.APIKey, APIKey: resolvedInput.APIKey,
RememberAPIKey: req.RememberAPIKey, RememberAPIKey: req.RememberAPIKey,
BaseURL: resolvedInput.BaseURL, BaseURL: resolvedInput.BaseURL,
APIType: req.APIType, APIType: resolvedInput.APIType,
AuthMode: resolvedInput.AuthMode,
VerifyModel: verifyModel,
Verified: true, Verified: true,
Remark: req.Remark, Remark: req.Remark,
} }
initialModels, err := buildInitialAgentAccountModels(account, req.Models)
if err != nil {
return err
}
if err := global.DB.Transaction(func(tx *gorm.DB) error { if err := global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(account).Error; err != nil { if err := tx.Create(account).Error; err != nil {
return err return err
@@ -1045,12 +1076,33 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
} }
func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error { func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID)) account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil { if err != nil {
return err return err
} }
if req.APIType != account.APIType {
return buserr.WithDetail("ErrInvalidParams", "API type cannot be changed", nil)
}
provider := account.Provider provider := account.Provider
resolvedInput, err := resolveAgentAccountInput(provider, req.APIKey, req.BaseURL) if err := ensureAgentAccountNameAvailable(provider, req.Name, account.ID); err != nil {
return err
}
models, err := loadAgentAccountModels(account)
if err != nil {
return err
}
requestedVerifyModel := req.VerifyModel
if strings.TrimSpace(requestedVerifyModel) == "" {
requestedVerifyModel = account.VerifyModel
}
verifyModel, err := resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
if err != nil {
return err
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
if err != nil { if err != nil {
return err return err
} }
@@ -1058,7 +1110,9 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account.APIKey = resolvedInput.APIKey account.APIKey = resolvedInput.APIKey
account.RememberAPIKey = req.RememberAPIKey account.RememberAPIKey = req.RememberAPIKey
account.BaseURL = resolvedInput.BaseURL account.BaseURL = resolvedInput.BaseURL
account.APIType = req.APIType account.APIType = resolvedInput.APIType
account.AuthMode = resolvedInput.AuthMode
account.VerifyModel = verifyModel
account.Remark = req.Remark account.Remark = req.Remark
account.Verified = true account.Verified = true
@@ -1080,6 +1134,12 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
if strings.TrimSpace(req.Provider) != "" { if strings.TrimSpace(req.Provider) != "" {
opts = append(opts, repo.WithByProvider(req.Provider)) opts = append(opts, repo.WithByProvider(req.Provider))
} }
if apiType := strings.TrimSpace(req.APIType); apiType != "" {
opts = append(opts, repo.WithByAPIType(apiType))
}
if req.TextOnly {
opts = append(opts, repo.WithTextAPIType())
}
if strings.TrimSpace(req.Name) != "" { if strings.TrimSpace(req.Name) != "" {
opts = append(opts, repo.WithByLikeName(req.Name)) opts = append(opts, repo.WithByLikeName(req.Name))
} }
@@ -1097,30 +1157,46 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
ID: item.ID, ID: item.ID,
MasterAccountID: item.MasterAccountID, MasterAccountID: item.MasterAccountID,
Provider: item.Provider, Provider: item.Provider,
ProviderName: providercatalog.DisplayName(item.Provider), ProviderName: localizedAgentProviderName(item.Provider),
Name: item.Name, Name: item.Name,
APIKey: apiKey, APIKey: apiKey,
RememberAPIKey: item.RememberAPIKey, RememberAPIKey: item.RememberAPIKey,
BaseURL: item.BaseURL, BaseURL: item.BaseURL,
Models: nil, Models: nil,
APIType: item.APIType, APIType: item.APIType,
AuthMode: item.AuthMode,
VerifyModel: item.VerifyModel,
Verified: item.Verified, Verified: item.Verified,
Remark: item.Remark, Remark: item.Remark,
CreatedAt: item.CreatedAt, CreatedAt: item.CreatedAt,
}) })
} }
for i := range items { if len(list) > 0 {
models, err := loadAgentAccountModels(&list[i]) accountIDs := make([]uint, 0, len(list))
if err != nil { for _, account := range list {
accountIDs = append(accountIDs, account.ID)
}
var rows []model.AgentAccountModel
if err := global.DB.Where("account_id IN ?", accountIDs).Order("account_id ASC, sort_order ASC, id ASC").Find(&rows).Error; err != nil {
return 0, nil, err return 0, nil, err
} }
items[i].Models = models modelsByAccount := make(map[uint][]dto.AgentAccountModel, len(list))
for _, row := range rows {
modelsByAccount[row.AccountID] = append(modelsByAccount[row.AccountID], dto.AgentAccountModel{
RecordID: row.ID,
ID: strings.TrimSpace(row.Model),
Name: strings.TrimSpace(row.Name),
})
}
for index, account := range list {
items[index].Models = modelsByAccount[account.ID]
}
} }
return count, items, nil return count, items, nil
} }
func (a AgentService) CountAccountsByProviders(req dto.AgentAccountProviderCountReq) (map[string]int64, error) { func (a AgentService) CountAccountsByProviders(req dto.AgentAccountProviderCountReq) (map[string]int64, error) {
return agentAccountRepo.CountByProviders(req.Providers) return agentAccountRepo.CountTextByProviders(req.Providers)
} }
func (a AgentService) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error) { func (a AgentService) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error) {
@@ -1131,6 +1207,22 @@ func (a AgentService) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.Agen
return loadAgentAccountModels(account) return loadAgentAccountModels(account)
} }
func (a AgentService) DiscoverAccountModels(req dto.AgentAccountModelDiscoverReq) ([]dto.AgentAccountModel, error) {
config, ok := providercatalog.FindAPIConfig(req.Provider, req.APIType)
if !ok || !config.DiscoverModels {
return nil, buserr.New("ErrAgentAccountModelsRequired")
}
baseURL, err := providercatalog.ResolveBaseURL(req.Provider, req.APIType, req.BaseURL)
if err != nil {
return nil, buserr.WithErr("ErrAgentAccountUnavailable", err)
}
models, err := providercatalog.DiscoverModels(baseURL, req.APIKey)
if err != nil {
return nil, buserr.WithErr("ErrAgentAccountUnavailable", err)
}
return buildDiscoveredAgentAccountModels(models), nil
}
func (a AgentService) CreateAccountModel(req dto.AgentAccountModelCreateReq) error { func (a AgentService) CreateAccountModel(req dto.AgentAccountModelCreateReq) error {
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID)) account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID))
if err != nil { if err != nil {
@@ -1140,24 +1232,19 @@ func (a AgentService) CreateAccountModel(req dto.AgentAccountModelCreateReq) err
if err != nil { if err != nil {
return err return err
} }
nextModel := cloneAgentAccountModel(req.Model) nextModel, err := normalizeAgentAccountModel(account, req.Model)
if _, ok := findAgentAccountModelForProvider(account.Provider, models, nextModel.ID); ok {
return buserr.New("ErrRecordExist")
}
inputPayload, err := json.Marshal(nextModel.Input)
if err != nil { if err != nil {
return err return err
} }
if _, ok := findAgentAccountModelForProvider(account.Provider, models, nextModel.ID); ok {
return buserr.New("ErrRecordExist")
}
sortOrder := len(models) + 1 sortOrder := len(models) + 1
record := &model.AgentAccountModel{ record := &model.AgentAccountModel{
AccountID: account.ID, AccountID: account.ID,
Model: nextModel.ID, Model: nextModel.ID,
Name: nextModel.Name, Name: nextModel.Name,
ContextWindow: nextModel.ContextWindow, SortOrder: sortOrder,
MaxTokens: nextModel.MaxTokens,
Reasoning: nextModel.Reasoning,
Input: string(inputPayload),
SortOrder: sortOrder,
} }
if err := agentAccountModelRepo.Create(record); err != nil { if err := agentAccountModelRepo.Create(record); err != nil {
return err return err
@@ -1178,7 +1265,11 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
if err != nil { if err != nil {
return err return err
} }
nextModel := cloneAgentAccountModel(req.Model) nextModel, err := normalizeAgentAccountModel(account, req.Model)
if err != nil {
return err
}
nextModel.RecordID = req.Model.RecordID
for _, item := range models { for _, item := range models {
if item.RecordID == req.Model.RecordID { if item.RecordID == req.Model.RecordID {
continue continue
@@ -1198,19 +1289,18 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
if err := ensureAccountModelsNotBound(account, nextModels); err != nil { if err := ensureAccountModelsNotBound(account, nextModels); err != nil {
return err return err
} }
inputPayload, err := json.Marshal(nextModel.Input) previousModelID := record.Model
if err != nil {
return err
}
record.Model = nextModel.ID record.Model = nextModel.ID
record.Name = nextModel.Name record.Name = nextModel.Name
record.ContextWindow = nextModel.ContextWindow
record.MaxTokens = nextModel.MaxTokens
record.Reasoning = nextModel.Reasoning
record.Input = string(inputPayload)
if err := agentAccountModelRepo.Save(record); err != nil { if err := agentAccountModelRepo.Save(record); err != nil {
return err return err
} }
if sameProviderModelID(account.Provider, account.VerifyModel, previousModelID) {
account.VerifyModel = nextModel.ID
if err := agentAccountRepo.Save(account); err != nil {
return err
}
}
terminalai.InvalidateTerminalRuntimeCache() terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache() terminalai.InvalidateFileAIRuntimeCache()
return a.syncAgentsByAccount(account) return a.syncAgentsByAccount(account)
@@ -1221,9 +1311,13 @@ func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) err
if err != nil { if err != nil {
return err return err
} }
if _, err := agentAccountModelRepo.GetFirst(repo.WithByID(req.RecordID), repo.WithByAccountID(req.AccountID)); err != nil { record, err := agentAccountModelRepo.GetFirst(repo.WithByID(req.RecordID), repo.WithByAccountID(req.AccountID))
if err != nil {
return err return err
} }
if sameProviderModelID(account.Provider, account.VerifyModel, record.Model) {
return buserr.New("ErrAgentVerifyModelInUse")
}
models, err := loadAgentAccountModels(account) models, err := loadAgentAccountModels(account)
if err != nil { if err != nil {
return err return err
@@ -1257,25 +1351,45 @@ 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.APIKey, req.BaseURL) _, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true)
return err return err
} }
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error { func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
if exists, _ := agentRepo.GetFirst(repo.WithByAccountID(req.ID)); exists != nil && exists.ID > 0 { agentAccountMutationMu.Lock()
return buserr.New("ErrAgentAccountBound") defer agentAccountMutationMu.Unlock()
} if err := global.DB.Transaction(func(tx *gorm.DB) error {
if aiStatus, _ := settingRepo.GetValueByKey("AIStatus"); strings.EqualFold(strings.TrimSpace(aiStatus), constant.StatusEnable) { var agentCount int64
if aiAccountID, _ := settingRepo.GetValueByKey("AIAccountID"); strings.TrimSpace(aiAccountID) == strconv.FormatUint(uint64(req.ID), 10) { if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
return err
}
if agentCount > 0 {
return buserr.New("ErrAgentAccountBound")
}
used, err := agentAccountUsedBySetting(tx, req.ID, "AIStatus", "AIAccountID")
if err != nil {
return err
}
if used {
return buserr.New("ErrTerminalAIAccountInUse") return buserr.New("ErrTerminalAIAccountInUse")
} }
} used, err = agentAccountUsedBySetting(tx, req.ID, "FileAIStatus", "FileAIAccountID")
if err := agentAccountModelRepo.Delete(repo.WithByAccountID(req.ID)); err != nil { if err != nil {
return err
}
if used {
return buserr.New("ErrFileAIAccountInUse")
}
if err := tx.Where("account_id = ?", req.ID).Delete(&model.AgentAccountModel{}).Error; err != nil {
return err
}
return tx.Delete(&model.AgentAccount{}, req.ID).Error
}); err != nil {
return err return err
} }
terminalai.InvalidateTerminalRuntimeCache() terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache() terminalai.InvalidateFileAIRuntimeCache()
return agentAccountRepo.DeleteByID(req.ID) return nil
} }
func (a AgentService) GetSecurityConfig(req dto.AgentIDReq) (*dto.AgentSecurityConfig, error) { func (a AgentService) GetSecurityConfig(req dto.AgentIDReq) (*dto.AgentSecurityConfig, error) {
@@ -1327,10 +1441,20 @@ func (a AgentService) GetOtherConfig(req dto.AgentIDReq) (*dto.AgentOtherConfig,
if err != nil { if err != nil {
return nil, err return nil, err
} }
auth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
return &dto.AgentOtherConfig{ return &dto.AgentOtherConfig{
UserTimezone: cfg.Timezone, UserTimezone: cfg.Timezone,
BrowserEnabled: true, BrowserEnabled: true,
NPMRegistry: "https://registry.npmjs.org/", NPMRegistry: "https://registry.npmjs.org/",
DashboardUsername: auth.Username,
DashboardPassword: auth.Password,
}, nil
}
if agent.AgentType == constant.AppCopaw {
auth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
return &dto.AgentOtherConfig{
DashboardUsername: auth.Username,
DashboardPassword: auth.Password,
}, nil }, nil
} }
conf, err := readOpenclawConfig(agent.ConfigPath) conf, err := readOpenclawConfig(agent.ConfigPath)
@@ -1351,18 +1475,36 @@ func (a AgentService) UpdateOtherConfig(req dto.AgentOtherConfigUpdateReq) error
return err return err
} }
if agent.AgentType == constant.AppHermesAgent { if agent.AgentType == constant.AppHermesAgent {
if strings.TrimSpace(req.UserTimezone) == "" {
return buserr.New("ErrInvalidParams")
}
account, err := agentAccountRepo.GetFirst(repo.WithByID(agent.AccountID)) account, err := agentAccountRepo.GetFirst(repo.WithByID(agent.AccountID))
if err != nil { if err != nil {
return err return err
} }
previousAuth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
nextAuth := normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
if err := writeHermesConfig(path.Dir(agent.ConfigPath), account, agent.Model, strings.TrimSpace(req.UserTimezone)); err != nil { if err := writeHermesConfig(path.Dir(agent.ConfigPath), account, agent.Model, strings.TrimSpace(req.UserTimezone)); err != nil {
return err return err
} }
if err := writeAgentDashboardAuthEnv(install.GetEnvPath(), agent.AgentType, nextAuth, true); err != nil {
return err
}
operate := constant.Restart
if previousAuth.Username != nextAuth.Username || previousAuth.Password != nextAuth.Password {
operate = constant.Rebuild
}
return NewIAppInstalledService().Operate(request.AppInstalledOperate{ return NewIAppInstalledService().Operate(request.AppInstalledOperate{
InstallId: install.ID, InstallId: install.ID,
Operate: constant.Restart, Operate: operate,
}) })
} }
if agent.AgentType == constant.AppCopaw {
return updateQwenPawDashboardAuth(install, normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword))
}
if strings.TrimSpace(req.UserTimezone) == "" || strings.TrimSpace(req.NPMRegistry) == "" {
return buserr.New("ErrInvalidParams")
}
if err := ensureContainerRunning(install.ContainerName); err != nil { if err := ensureContainerRunning(install.ContainerName); err != nil {
return err return err
} }
@@ -1534,7 +1676,7 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
return err return err
} }
modelName := resolvedRuntime.StoredModel modelName := resolvedRuntime.StoredModel
apiType, maxTokens, contextWindow := resolvedRuntime.APIType, resolvedRuntime.MaxTokens, resolvedRuntime.ContextWindow apiType := resolvedRuntime.APIType
confDir := path.Dir(agent.ConfigPath) confDir := path.Dir(agent.ConfigPath)
switch agent.AgentType { switch agent.AgentType {
case constant.AppOpenclaw: case constant.AppOpenclaw:
@@ -1543,7 +1685,7 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
return err return err
} }
fallbacks := extractOpenclawFallbackModelIDs(conf, account, accountModels, selectedAccountModel.ID) fallbacks := extractOpenclawFallbackModelIDs(conf, account, accountModels, selectedAccountModel.ID)
if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil, fallbacks); err != nil { if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil, fallbacks, nil); err != nil {
return err return err
} }
case constant.AppHermesAgent: case constant.AppHermesAgent:
@@ -1562,8 +1704,6 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
agent.Provider = account.Provider agent.Provider = account.Provider
agent.Model = modelName agent.Model = modelName
agent.APIType = apiType agent.APIType = apiType
agent.MaxTokens = maxTokens
agent.ContextWindow = contextWindow
_ = agentRepo.Save(&agent) _ = agentRepo.Save(&agent)
} }
return nil return nil
+45 -7
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path" "path"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -811,9 +812,7 @@ func extractTelegramConfig(conf map[string]interface{}) dto.AgentTelegramConfig
result.RequireMention = result.GroupPolicy == "allowlist" result.RequireMention = result.GroupPolicy == "allowlist"
result.GroupAllowFrom = extractStringList(telegram["groupAllowFrom"]) result.GroupAllowFrom = extractStringList(telegram["groupAllowFrom"])
result.Proxy = extractStringValue(telegram["proxy"]) result.Proxy = extractStringValue(telegram["proxy"])
if streaming := extractStringValue(telegram["streaming"]); streaming != "" { result.Streaming = normalizeTelegramStreamingMode(telegram["streaming"], result.Streaming)
result.Streaming = streaming
}
accounts := childMap(telegram, "accounts") accounts := childMap(telegram, "accounts")
if len(accounts) == 0 { if len(accounts) == 0 {
botToken := extractStringValue(telegram["botToken"]) botToken := extractStringValue(telegram["botToken"])
@@ -839,7 +838,7 @@ func extractTelegramConfig(conf map[string]interface{}) dto.AgentTelegramConfig
BotToken: extractStringValue(account["botToken"]), BotToken: extractStringValue(account["botToken"]),
DmPolicy: extractStringValue(account["dmPolicy"]), DmPolicy: extractStringValue(account["dmPolicy"]),
GroupPolicy: extractStringValue(account["groupPolicy"]), GroupPolicy: extractStringValue(account["groupPolicy"]),
Streaming: extractStringValue(account["streaming"]), Streaming: normalizeTelegramStreamingMode(account["streaming"], result.Streaming),
}) })
} }
result.DefaultAccount = normalizeDefaultAccount(extractStringValue(telegram["defaultAccount"]), getTelegramBotAccountIDs(bots)) result.DefaultAccount = normalizeDefaultAccount(extractStringValue(telegram["defaultAccount"]), getTelegramBotAccountIDs(bots))
@@ -879,7 +878,7 @@ func setTelegramConfig(conf map[string]interface{}, config dto.AgentTelegramConf
} else { } else {
delete(telegram, "proxy") delete(telegram, "proxy")
} }
telegram["streaming"] = config.Streaming telegram["streaming"] = buildTelegramStreamingConfig(config.Streaming)
accounts := make(map[string]interface{}, len(config.Bots)) accounts := make(map[string]interface{}, len(config.Bots))
for _, bot := range config.Bots { for _, bot := range config.Bots {
account := map[string]interface{}{ account := map[string]interface{}{
@@ -888,7 +887,7 @@ func setTelegramConfig(conf map[string]interface{}, config dto.AgentTelegramConf
"botToken": bot.BotToken, "botToken": bot.BotToken,
"dmPolicy": bot.DmPolicy, "dmPolicy": bot.DmPolicy,
"groupPolicy": bot.GroupPolicy, "groupPolicy": bot.GroupPolicy,
"streaming": bot.Streaming, "streaming": buildTelegramStreamingConfig(bot.Streaming),
} }
if bot.DmPolicy == "open" { if bot.DmPolicy == "open" {
account["allowFrom"] = []string{"*"} account["allowFrom"] = []string{"*"}
@@ -899,6 +898,29 @@ func setTelegramConfig(conf map[string]interface{}, config dto.AgentTelegramConf
delete(telegram, "botToken") delete(telegram, "botToken")
} }
func normalizeTelegramStreamingMode(value interface{}, defaultMode string) string {
mode := defaultMode
switch typed := value.(type) {
case string:
mode = typed
case map[string]interface{}:
mode = extractStringValue(typed["mode"])
}
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case "off", "partial", "block", "progress":
return mode
default:
return "partial"
}
}
func buildTelegramStreamingConfig(mode string) map[string]interface{} {
return map[string]interface{}{
"mode": normalizeTelegramStreamingMode(mode, "partial"),
}
}
func extractDiscordConfig(conf map[string]interface{}) dto.AgentDiscordConfig { func extractDiscordConfig(conf map[string]interface{}) dto.AgentDiscordConfig {
result := dto.AgentDiscordConfig{Enabled: true, DmPolicy: "pairing", AllowFrom: []string{}, RequireMention: false, GroupPolicy: "open"} result := dto.AgentDiscordConfig{Enabled: true, DmPolicy: "pairing", AllowFrom: []string{}, RequireMention: false, GroupPolicy: "open"}
discord := getChannelConfig(conf, "discord") discord := getChannelConfig(conf, "discord")
@@ -1299,6 +1321,10 @@ func appendPluginAllow(conf map[string]interface{}, pluginID string) {
} }
func installOpenclawPlugin(mgr *cmd.CommandHelper, containerName, spec, pluginID string) error { func installOpenclawPlugin(mgr *cmd.CommandHelper, containerName, spec, pluginID string) error {
help, err := cmd.RunDockerExecWithStdout(time.Minute, containerName, "openclaw", "plugins", "install", "--help")
if err != nil {
return err
}
workdir := path.Join(openclawPluginPackageTmpDir, pluginID) workdir := path.Join(openclawPluginPackageTmpDir, pluginID)
defer func() { defer func() {
_ = mgr.Run("docker", "exec", containerName, "rm", "-rf", workdir) _ = mgr.Run("docker", "exec", containerName, "rm", "-rf", workdir)
@@ -1320,7 +1346,19 @@ func installOpenclawPlugin(mgr *cmd.CommandHelper, containerName, spec, pluginID
if pkgPath == "" { if pkgPath == "" {
return fmt.Errorf("openclaw plugin package not found") return fmt.Errorf("openclaw plugin package not found")
} }
return mgr.Run("docker", "exec", containerName, "openclaw", "plugins", "install", pkgPath, "--dangerously-force-unsafe-install") args := []string{"exec", containerName, "openclaw", "plugins", "install", pkgPath}
// Newer CLIs require source confirmation; older releases do not support --force.
options := strings.Fields(help)
if slices.Contains(options, "--force") {
args = append(args, "--force")
} else if slices.Contains(options, "--dangerously-force-unsafe-install") {
args = append(args, "--dangerously-force-unsafe-install")
}
// Source confirmation does not grant the selected channel plugin's capabilities.
if slices.Contains(options, "--accept-capabilities") {
args = append(args, "--accept-capabilities")
}
return mgr.Run("docker", args...)
} }
func uninstallOpenclawPlugin(mgr *cmd.CommandHelper, containerName, pluginID string) error { func uninstallOpenclawPlugin(mgr *cmd.CommandHelper, containerName, pluginID string) error {
+128
View File
@@ -0,0 +1,128 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
)
type qwenPawAuthStatus struct {
Enabled bool `json:"enabled"`
HasUsers bool `json:"has_users"`
}
type qwenPawLoginResponse struct {
Token string `json:"token"`
}
func updateQwenPawDashboardAuth(install *model.AppInstall, next agentDashboardAuth) error {
if install == nil || install.ID == 0 {
return buserr.New("ErrRecordNotFound")
}
current, err := readAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw)
if err != nil {
return err
}
if current == next {
return writeAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw, next, true)
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
baseURL := fmt.Sprintf("http://127.0.0.1:%d/api/auth", install.HttpPort)
var status qwenPawAuthStatus
if _, err := requestQwenPawAuth(http.MethodGet, baseURL+"/status", nil, "", &status); err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
if !status.Enabled {
return buserr.New("ErrQwenPawAuthDisabled")
}
if !status.HasUsers {
payload := map[string]string{"username": next.Username, "password": next.Password}
if _, err := requestQwenPawAuth(http.MethodPost, baseURL+"/register", payload, "", nil); err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
} else {
var login qwenPawLoginResponse
payload := map[string]string{"username": current.Username, "password": current.Password}
statusCode, err := requestQwenPawAuth(http.MethodPost, baseURL+"/login", payload, "", &login)
if statusCode == http.StatusUnauthorized {
return buserr.New("ErrQwenPawAuthOutOfSync")
}
if err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
payload = map[string]string{"current_password": current.Password}
if current.Username != next.Username {
payload["new_username"] = next.Username
}
if current.Password != next.Password {
payload["new_password"] = next.Password
}
if _, err := requestQwenPawAuth(http.MethodPost, baseURL+"/update-profile", payload, login.Token, nil); err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
}
return writeAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw, next, true)
}
func requestQwenPawAuth(method, reqURL string, payload interface{}, token string, result interface{}) (int, error) {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return 0, err
}
body = bytes.NewReader(data)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return resp.StatusCode, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
detail := strings.TrimSpace(string(data))
var errorResponse struct {
Detail string `json:"detail"`
}
if json.Unmarshal(data, &errorResponse) == nil && strings.TrimSpace(errorResponse.Detail) != "" {
detail = strings.TrimSpace(errorResponse.Detail)
}
if detail == "" {
detail = resp.Status
}
return resp.StatusCode, errors.New(detail)
}
if result != nil && len(data) > 0 {
if err := json.Unmarshal(data, result); err != nil {
return resp.StatusCode, err
}
}
return resp.StatusCode, nil
}
+31 -68
View File
@@ -13,9 +13,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/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/common"
agentenv "github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/joho/godotenv"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -71,7 +69,7 @@ func writeHermesConfig(confDir string, account *model.AgentAccount, modelName st
} }
} }
provider := resolveHermesProvider(account.Provider) provider := resolveHermesProvider(account.Provider, account.APIType)
configPath := path.Join(confDir, "config.yaml") configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath) cfg, err := readHermesConfigMap(configPath)
if err != nil { if err != nil {
@@ -152,7 +150,7 @@ func writeHermesConfigMap(configPath string, cfg map[string]interface{}) error {
} }
func readHermesTelegramChannelConfig(confDir string) (*dto.AgentTelegramConfig, error) { func readHermesTelegramChannelConfig(confDir string) (*dto.AgentTelegramConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env")) envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -209,7 +207,7 @@ func readHermesTelegramChannelConfig(confDir string) (*dto.AgentTelegramConfig,
func writeHermesTelegramChannelConfig(confDir string, config dto.AgentTelegramConfig) error { func writeHermesTelegramChannelConfig(confDir string, config dto.AgentTelegramConfig) error {
envPath := path.Join(confDir, ".env") envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath) envMap, err := readAgentEnvMap(envPath)
if err != nil { if err != nil {
return err return err
} }
@@ -229,7 +227,7 @@ func writeHermesTelegramChannelConfig(confDir string, config dto.AgentTelegramCo
envMap["TELEGRAM_ALLOWED_USERS"] = allow envMap["TELEGRAM_ALLOWED_USERS"] = allow
} }
} }
if err := writeHermesEnvMap(envPath, envMap, []string{ if err := writeAgentEnvMap(envPath, envMap, []string{
"TELEGRAM_BOT_TOKEN", "TELEGRAM_BOT_TOKEN",
"TELEGRAM_ALLOWED_USERS", "TELEGRAM_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS", "TELEGRAM_ALLOW_ALL_USERS",
@@ -248,7 +246,7 @@ func writeHermesTelegramChannelConfig(confDir string, config dto.AgentTelegramCo
} }
func readHermesDiscordChannelConfig(confDir string) (*dto.AgentDiscordConfig, error) { func readHermesDiscordChannelConfig(confDir string) (*dto.AgentDiscordConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env")) envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -300,7 +298,7 @@ func readHermesDiscordChannelConfig(confDir string) (*dto.AgentDiscordConfig, er
func writeHermesDiscordChannelConfig(confDir string, config dto.AgentDiscordConfig) error { func writeHermesDiscordChannelConfig(confDir string, config dto.AgentDiscordConfig) error {
envPath := path.Join(confDir, ".env") envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath) envMap, err := readAgentEnvMap(envPath)
if err != nil { if err != nil {
return err return err
} }
@@ -320,7 +318,7 @@ func writeHermesDiscordChannelConfig(confDir string, config dto.AgentDiscordConf
envMap["DISCORD_ALLOWED_USERS"] = allow envMap["DISCORD_ALLOWED_USERS"] = allow
} }
} }
if err := writeHermesEnvMap(envPath, envMap, []string{ if err := writeAgentEnvMap(envPath, envMap, []string{
"DISCORD_BOT_TOKEN", "DISCORD_BOT_TOKEN",
"DISCORD_ALLOWED_USERS", "DISCORD_ALLOWED_USERS",
"DISCORD_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS",
@@ -340,14 +338,14 @@ func writeHermesDiscordChannelConfig(confDir string, config dto.AgentDiscordConf
func deleteHermesEnvKeys(confDir string, keys ...string) error { func deleteHermesEnvKeys(confDir string, keys ...string) error {
envPath := path.Join(confDir, ".env") envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath) envMap, err := readAgentEnvMap(envPath)
if err != nil { if err != nil {
return err return err
} }
for _, key := range keys { for _, key := range keys {
delete(envMap, key) delete(envMap, key)
} }
return writeHermesEnvMap(envPath, envMap, keys) return writeAgentEnvMap(envPath, envMap, keys)
} }
func deleteHermesConfigSections(confDir string, topLevelKeys []string, platformKeys []string) error { func deleteHermesConfigSections(confDir string, topLevelKeys []string, platformKeys []string) error {
@@ -407,11 +405,18 @@ func normalizeHermesTimezone(timezone string) string {
return timezone return timezone
} }
func resolveHermesProvider(provider string) string { func resolveHermesProvider(provider, apiType string) string {
if apiType == "anthropic-messages" && (provider == "deepseek" || provider == "bailian-coding-plan" || provider == "ark-coding-plan" || provider == "xiaomi") {
return "anthropic"
}
switch provider { switch provider {
case "": case "":
return "custom" return "custom"
case "openrouter", "anthropic", "gemini", "zai", "kimi-coding", "xiaomi": case "moonshot":
return "kimi-coding"
case "kimi":
return "kimi-coding-cn"
case "openrouter", "anthropic", "deepseek", "gemini", "zai", "kimi-coding", "xiaomi":
return provider return provider
case "minimax": case "minimax":
return "minimax-cn" return "minimax-cn"
@@ -428,17 +433,7 @@ func resolveHermesModel(sourceProvider, targetProvider, modelName string) string
if targetProvider != "custom" { if targetProvider != "custom" {
return target return target
} }
if sourceProvider == "custom" || sourceProvider == "vllm" { return providercatalog.NormalizeModelID(sourceProvider, target)
return normalizeCustomModel(target)
}
if strings.Contains(target, "/") {
parts := strings.SplitN(target, "/", 2)
model := strings.TrimSpace(parts[1])
if model != "" {
return model
}
}
return target
} }
func resolveHermesConfiguredModelID(account *model.AgentAccount, accountModels []dto.AgentAccountModel, configuredModel string) (string, error) { func resolveHermesConfiguredModelID(account *model.AgentAccount, accountModels []dto.AgentAccountModel, configuredModel string) (string, error) {
@@ -449,7 +444,7 @@ func resolveHermesConfiguredModelID(account *model.AgentAccount, accountModels [
if configuredModel == "" { if configuredModel == "" {
return "", buserr.New("ErrAgentModelNotInAccount") return "", buserr.New("ErrAgentModelNotInAccount")
} }
provider := resolveHermesProvider(account.Provider) provider := resolveHermesProvider(account.Provider, account.APIType)
for _, item := range accountModels { for _, item := range accountModels {
if resolveHermesModel(account.Provider, provider, item.ID) == configuredModel { if resolveHermesModel(account.Provider, provider, item.ID) == configuredModel {
return item.ID, nil return item.ID, nil
@@ -462,7 +457,8 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
if account == nil { if account == nil {
return nil return nil
} }
if resolveHermesProvider(account.Provider) == "custom" { resolvedProvider := resolveHermesProvider(account.Provider, account.APIType)
if resolvedProvider == "custom" {
if account.APIKey == "" { if account.APIKey == "" {
return nil return nil
} }
@@ -480,6 +476,11 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
} }
entries = append(entries, hermesEnvEntry{Key: key, Value: value}) entries = append(entries, hermesEnvEntry{Key: key, Value: value})
} }
if resolvedProvider == "anthropic" {
appendEntry("ANTHROPIC_API_KEY", apiKey)
appendEntry("ANTHROPIC_BASE_URL", baseURL)
return entries
}
switch account.Provider { switch account.Provider {
case "openrouter": case "openrouter":
@@ -487,6 +488,7 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
appendEntry("OPENROUTER_BASE_URL", baseURL) appendEntry("OPENROUTER_BASE_URL", baseURL)
case "anthropic": case "anthropic":
appendEntry("ANTHROPIC_API_KEY", apiKey) appendEntry("ANTHROPIC_API_KEY", apiKey)
appendEntry("ANTHROPIC_BASE_URL", baseURL)
case "gemini": case "gemini":
appendEntry("GOOGLE_API_KEY", apiKey) appendEntry("GOOGLE_API_KEY", apiKey)
appendEntry("GEMINI_API_KEY", apiKey) appendEntry("GEMINI_API_KEY", apiKey)
@@ -532,7 +534,7 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
} }
func writeHermesModelEnv(envPath string, account *model.AgentAccount) error { func writeHermesModelEnv(envPath string, account *model.AgentAccount) error {
envMap, err := readHermesEnvMap(envPath) envMap, err := readAgentEnvMap(envPath)
if err != nil { if err != nil {
return err return err
} }
@@ -548,7 +550,7 @@ func writeHermesModelEnv(envPath string, account *model.AgentAccount) error {
envMap[entry.Key] = entry.Value envMap[entry.Key] = entry.Value
order = append(order, entry.Key) order = append(order, entry.Key)
} }
return writeHermesEnvMap(envPath, envMap, order) return writeAgentEnvMap(envPath, envMap, order)
} }
func hermesManagedModelEnvKeys() []string { func hermesManagedModelEnvKeys() []string {
@@ -556,6 +558,7 @@ func hermesManagedModelEnvKeys() []string {
"OPENROUTER_API_KEY", "OPENROUTER_API_KEY",
"OPENROUTER_BASE_URL", "OPENROUTER_BASE_URL",
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"GOOGLE_API_KEY", "GOOGLE_API_KEY",
"GEMINI_API_KEY", "GEMINI_API_KEY",
"GEMINI_BASE_URL", "GEMINI_BASE_URL",
@@ -598,46 +601,6 @@ func hermesManagedModelEnvKeys() []string {
return result return result
} }
func writeHermesEnv(envPath string, entries []hermesEnvEntry) error {
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
for _, entry := range entries {
if entry.Key == "" || entry.Value == "" {
continue
}
envMap[entry.Key] = entry.Value
}
order := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.Key == "" {
continue
}
order = append(order, entry.Key)
}
return writeHermesEnvMap(envPath, envMap, order)
}
func readHermesEnvMap(envPath string) (map[string]string, error) {
fileOp := files.NewFileOp()
if !fileOp.Stat(envPath) {
return map[string]string{}, nil
}
envMap, err := godotenv.Read(envPath)
if err != nil {
return nil, err
}
return envMap, nil
}
func writeHermesEnvMap(envPath string, envMap map[string]string, order []string) error {
if len(envMap) == 0 {
return files.NewFileOp().SaveFile(envPath, "", 0600)
}
return agentenv.WriteWithOrder(envMap, envPath, order)
}
func splitHermesEnvList(value string) []string { func splitHermesEnvList(value string) []string {
if value == "" { if value == "" {
return []string{} return []string{}
+13 -13
View File
@@ -34,7 +34,7 @@ print('Restart the Hermes-Agent container to apply the new Weixin settings.')
` `
func readHermesQQBotChannelConfig(confDir string) (*dto.AgentQQBotConfig, error) { func readHermesQQBotChannelConfig(confDir string) (*dto.AgentQQBotConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env")) envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -78,7 +78,7 @@ func readHermesQQBotChannelConfig(confDir string) (*dto.AgentQQBotConfig, error)
func writeHermesQQBotChannelConfig(confDir string, config dto.AgentQQBotConfig) error { func writeHermesQQBotChannelConfig(confDir string, config dto.AgentQQBotConfig) error {
envPath := path.Join(confDir, ".env") envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath) envMap, err := readAgentEnvMap(envPath)
if err != nil { if err != nil {
return err return err
} }
@@ -102,7 +102,7 @@ func writeHermesQQBotChannelConfig(confDir string, config dto.AgentQQBotConfig)
envMap["QQ_GROUP_ALLOWED_USERS"] = allow envMap["QQ_GROUP_ALLOWED_USERS"] = allow
} }
} }
if err := writeHermesEnvMap(envPath, envMap, []string{ if err := writeAgentEnvMap(envPath, envMap, []string{
"QQ_APP_ID", "QQ_APP_ID",
"QQ_CLIENT_SECRET", "QQ_CLIENT_SECRET",
"QQ_ALLOW_ALL_USERS", "QQ_ALLOW_ALL_USERS",
@@ -163,7 +163,7 @@ func deleteHermesQQBotChannelConfig(confDir string) error {
} }
func readHermesWecomChannelConfig(confDir string) (*dto.AgentWecomConfig, error) { func readHermesWecomChannelConfig(confDir string) (*dto.AgentWecomConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env")) envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -194,7 +194,7 @@ func readHermesWecomChannelConfig(confDir string) (*dto.AgentWecomConfig, error)
func writeHermesWecomChannelConfig(confDir string, config dto.AgentWecomConfig) error { func writeHermesWecomChannelConfig(confDir string, config dto.AgentWecomConfig) error {
envPath := path.Join(confDir, ".env") envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath) envMap, err := readAgentEnvMap(envPath)
if err != nil { if err != nil {
return err return err
} }
@@ -229,7 +229,7 @@ func writeHermesWecomChannelConfig(confDir string, config dto.AgentWecomConfig)
envMap["WECOM_GROUP_ALLOWED_USERS"] = allow envMap["WECOM_GROUP_ALLOWED_USERS"] = allow
} }
} }
if err := writeHermesEnvMap(envPath, envMap, []string{ if err := writeAgentEnvMap(envPath, envMap, []string{
"WECOM_BOT_ID", "WECOM_BOT_ID",
"WECOM_SECRET", "WECOM_SECRET",
"WECOM_DM_POLICY", "WECOM_DM_POLICY",
@@ -282,7 +282,7 @@ func deleteHermesWecomChannelConfig(confDir string) error {
} }
func readHermesDingTalkChannelConfig(confDir string) (*dto.AgentDingTalkConfig, error) { func readHermesDingTalkChannelConfig(confDir string) (*dto.AgentDingTalkConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env")) envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -332,7 +332,7 @@ func readHermesDingTalkChannelConfig(confDir string) (*dto.AgentDingTalkConfig,
func writeHermesDingTalkChannelConfig(confDir string, config dto.AgentDingTalkConfig) error { func writeHermesDingTalkChannelConfig(confDir string, config dto.AgentDingTalkConfig) error {
envPath := path.Join(confDir, ".env") envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath) envMap, err := readAgentEnvMap(envPath)
if err != nil { if err != nil {
return err return err
} }
@@ -356,7 +356,7 @@ func writeHermesDingTalkChannelConfig(confDir string, config dto.AgentDingTalkCo
} else if config.DmPolicy == "open" { } else if config.DmPolicy == "open" {
envMap["DINGTALK_ALLOW_ALL_USERS"] = "true" envMap["DINGTALK_ALLOW_ALL_USERS"] = "true"
} }
if err := writeHermesEnvMap(envPath, envMap, []string{ if err := writeAgentEnvMap(envPath, envMap, []string{
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_ID",
"DINGTALK_CLIENT_SECRET", "DINGTALK_CLIENT_SECRET",
"DINGTALK_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS",
@@ -398,7 +398,7 @@ func deleteHermesDingTalkChannelConfig(confDir string) error {
} }
func readHermesFeishuChannelConfig(confDir string) (*dto.AgentFeishuConfig, error) { func readHermesFeishuChannelConfig(confDir string) (*dto.AgentFeishuConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env")) envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -452,7 +452,7 @@ func readHermesFeishuChannelConfig(confDir string) (*dto.AgentFeishuConfig, erro
func writeHermesFeishuChannelConfig(confDir string, config dto.AgentFeishuConfig) error { func writeHermesFeishuChannelConfig(confDir string, config dto.AgentFeishuConfig) error {
envPath := path.Join(confDir, ".env") envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath) envMap, err := readAgentEnvMap(envPath)
if err != nil { if err != nil {
return err return err
} }
@@ -488,7 +488,7 @@ func writeHermesFeishuChannelConfig(confDir string, config dto.AgentFeishuConfig
envMap["FEISHU_ALLOWED_USERS"] = allow envMap["FEISHU_ALLOWED_USERS"] = allow
} }
} }
if err := writeHermesEnvMap(envPath, envMap, []string{ if err := writeAgentEnvMap(envPath, envMap, []string{
"FEISHU_APP_ID", "FEISHU_APP_ID",
"FEISHU_APP_SECRET", "FEISHU_APP_SECRET",
"FEISHU_DOMAIN", "FEISHU_DOMAIN",
@@ -529,7 +529,7 @@ func deleteHermesFeishuChannelConfig(confDir string) error {
} }
func readHermesWeixinChannelConfig(confDir string) (*dto.AgentWeixinConfig, error) { func readHermesWeixinChannelConfig(confDir string) (*dto.AgentWeixinConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env")) envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
+315
View File
@@ -0,0 +1,315 @@
package service
import (
"database/sql"
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
"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/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
)
var (
openclawPluginPackagePattern = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$`)
openclawPluginVersionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z._-]*$`)
openclawPluginIDPattern = regexp.MustCompile(`^(@[A-Za-z0-9][A-Za-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*$`)
)
type openclawPluginListOutput struct {
Plugins []struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Origin string `json:"origin"`
Enabled bool `json:"enabled"`
} `json:"plugins"`
}
type openclawPluginIndexItem struct {
PluginID string `json:"pluginId"`
PackageName string `json:"packageName"`
PackageVersion string `json:"packageVersion"`
Origin string `json:"origin"`
Enabled bool `json:"enabled"`
}
type openclawPluginSearchOutput struct {
Results []struct {
Score float64 `json:"score"`
Package struct {
Name string `json:"name"`
RuntimeID string `json:"runtimeId"`
DisplayName string `json:"displayName"`
Summary string `json:"summary"`
LatestVersion string `json:"latestVersion"`
Categories []string `json:"categories"`
Channel string `json:"channel"`
IsOfficial bool `json:"isOfficial"`
VerificationTier string `json:"verificationTier"`
Stats struct {
Downloads int64 `json:"downloads"`
} `json:"stats"`
} `json:"package"`
} `json:"results"`
}
func (a AgentService) ListPlugins(req dto.AgentPluginsReq) ([]dto.AgentPluginItem, error) {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if plugins, err := readOpenclawPluginIndex(filepath.Join(filepath.Dir(agent.ConfigPath), "state", "openclaw.sqlite")); err == nil {
return plugins, nil
}
output, err := cmd.RunDockerExecWithStdout(2*time.Minute, install.ContainerName, "openclaw", "plugins", "list", "--json")
if err != nil {
return nil, err
}
return parseOpenclawPluginList([]byte(output))
}
func (a AgentService) SearchPlugins(req dto.AgentPluginSearchReq) ([]dto.AgentPluginSearchItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
limit := req.Limit
if limit == 0 {
limit = 20
}
output, err := cmd.RunDockerExecWithStdout(
2*time.Minute,
install.ContainerName,
"openclaw", "plugins", "search", strings.TrimSpace(req.Keyword), "--limit", fmt.Sprint(limit), "--json",
)
if err != nil {
return nil, err
}
return parseOpenclawPluginSearch([]byte(output))
}
func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) error {
spec, err := buildOpenclawPluginInstallSpec(req.Package, req.Version)
if err != nil {
return err
}
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
taskName := fmt.Sprintf("%s [%s]", i18n.GetMsgByKey("AgentPluginInstall"), req.Package)
installTask, err := task.NewTask(taskName, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
installTask.AddSubTask(taskName, func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
return mgr.Run("docker", "exec", install.ContainerName, "openclaw", "plugins", "install", spec)
}, nil)
addOpenclawPluginRestartTask(installTask, install)
go executeAgentPluginTask(installTask)
return nil
}
func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
if !openclawPluginIDPattern.MatchString(req.PluginID) {
return buserr.New("ErrInvalidChar")
}
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
if req.Operate == "update" || req.Operate == "uninstall" {
plugins, err := a.ListPlugins(dto.AgentPluginsReq{AgentID: req.AgentID})
if err != nil {
return err
}
for _, plugin := range plugins {
if plugin.ID == req.PluginID && plugin.Origin == "bundled" {
return buserr.WithName("ErrNotSupportType", req.Operate)
}
}
}
taskType := map[string]string{
"enable": task.TaskUpdate,
"disable": task.TaskUpdate,
"update": task.TaskUpgrade,
"uninstall": task.TaskUninstall,
}[req.Operate]
taskName := fmt.Sprintf("%s [%s]", i18n.GetMsgByKey(map[string]string{
"enable": "AgentPluginEnable",
"disable": "AgentPluginDisable",
"update": "AgentPluginUpdate",
"uninstall": "AgentPluginUninstall",
}[req.Operate]), req.PluginID)
operateTask, err := task.NewTask(taskName, taskType, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
operateTask.AddSubTask(taskName, func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
if req.Operate == "uninstall" {
if err := uninstallOpenclawPlugin(mgr, install.ContainerName, req.PluginID); err != nil {
return err
}
return cleanupManagedOpenclawPlugin(agent, req.PluginID)
}
return mgr.Run("docker", "exec", install.ContainerName, "openclaw", "plugins", req.Operate, req.PluginID)
}, nil)
addOpenclawPluginRestartTask(operateTask, install)
go executeAgentPluginTask(operateTask)
return nil
}
func parseOpenclawPluginList(raw []byte) ([]dto.AgentPluginItem, error) {
payload, err := extractEmbeddedJSON(string(raw))
if err != nil {
return nil, err
}
if len(payload) == 0 {
return []dto.AgentPluginItem{}, nil
}
var output openclawPluginListOutput
if err := json.Unmarshal(payload, &output); err != nil {
return nil, err
}
items := make([]dto.AgentPluginItem, 0, len(output.Plugins))
for _, plugin := range output.Plugins {
items = append(items, dto.AgentPluginItem{
ID: plugin.ID,
Name: plugin.Name,
Version: plugin.Version,
Origin: plugin.Origin,
Enabled: plugin.Enabled,
})
}
return items, nil
}
func readOpenclawPluginIndex(dbPath string) ([]dto.AgentPluginItem, error) {
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
var raw []byte
if err := db.QueryRow(
"SELECT plugins_json FROM installed_plugin_index WHERE index_key = ?",
"installed-plugin-index",
).Scan(&raw); err != nil {
return nil, err
}
var plugins []openclawPluginIndexItem
if err := json.Unmarshal(raw, &plugins); err != nil {
return nil, err
}
items := make([]dto.AgentPluginItem, 0, len(plugins))
for _, plugin := range plugins {
name := plugin.PackageName
if name == "" {
name = plugin.PluginID
}
items = append(items, dto.AgentPluginItem{
ID: plugin.PluginID,
Name: name,
Version: plugin.PackageVersion,
Origin: plugin.Origin,
Enabled: plugin.Enabled,
})
}
return items, nil
}
func parseOpenclawPluginSearch(raw []byte) ([]dto.AgentPluginSearchItem, error) {
payload, err := extractEmbeddedJSON(string(raw))
if err != nil {
return nil, err
}
if len(payload) == 0 {
return []dto.AgentPluginSearchItem{}, nil
}
var output openclawPluginSearchOutput
if err := json.Unmarshal(payload, &output); err != nil {
return nil, err
}
items := make([]dto.AgentPluginSearchItem, 0, len(output.Results))
for _, result := range output.Results {
items = append(items, dto.AgentPluginSearchItem{
Package: result.Package.Name,
PluginID: result.Package.RuntimeID,
Name: result.Package.DisplayName,
Description: result.Package.Summary,
Version: result.Package.LatestVersion,
Channel: result.Package.Channel,
VerificationTier: result.Package.VerificationTier,
Categories: append([]string{}, result.Package.Categories...),
Official: result.Package.IsOfficial,
Downloads: result.Package.Stats.Downloads,
Score: result.Score,
})
}
return items, nil
}
func buildOpenclawPluginInstallSpec(packageName, version string) (string, error) {
packageName = strings.TrimSpace(packageName)
version = strings.TrimSpace(version)
if !openclawPluginPackagePattern.MatchString(packageName) || !openclawPluginVersionPattern.MatchString(version) {
return "", buserr.New("ErrInvalidChar")
}
return "clawhub:" + packageName + "@" + version, nil
}
func cleanupManagedOpenclawPlugin(agent *model.Agent, pluginID string) error {
pluginType := map[string]string{
"openclaw-lark": "feishu",
"openclaw-qqbot": "qqbot",
"wecom-openclaw-plugin": "wecom",
"dingtalk-connector": "dingtalk",
"openclaw-weixin": "weixin",
}[pluginID]
if pluginType == "" {
return nil
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
cleanupOpenclawPluginConfig(conf, pluginType)
return writeOpenclawConfigRaw(agent.ConfigPath, conf)
}
func addOpenclawPluginRestartTask(t *task.Task, install *model.AppInstall) {
t.AddSubTask(task.GetTaskName("OpenClaw", task.TaskRestart, task.TaskScopeAI), func(t *task.Task) error {
output, err := compose.Restart(install.GetComposePath())
if output != "" {
t.Log(output)
}
return err
}, nil)
}
func executeAgentPluginTask(t *task.Task) {
if err := t.Execute(); err != nil {
global.LOG.Errorf("operate openclaw plugin failed: %v", err)
}
}
+425 -276
View File
@@ -4,10 +4,13 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -19,9 +22,12 @@ 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"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/common"
agentenv "github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper" "github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/joho/godotenv"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -35,6 +41,39 @@ type resolvedAgentAccountInput struct {
Provider string Provider string
APIKey string APIKey string
BaseURL string BaseURL string
APIType string
AuthMode string
}
func ensureAgentAccountNameAvailable(provider, name string, excludeID uint) error {
opts := []repo.DBOption{repo.WithByProvider(provider), repo.WithByName(name)}
if excludeID > 0 {
opts = append(opts, repo.WithByNOTID(excludeID))
}
account, err := agentAccountRepo.GetFirst(opts...)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if account != nil && account.ID > 0 {
return buserr.New("ErrRecordExist")
}
return nil
}
func agentAccountUsedBySetting(tx *gorm.DB, accountID uint, statusKey, accountIDKey string) (bool, error) {
var settings []model.Setting
if err := tx.Where("key IN ?", []string{statusKey, accountIDKey}).Find(&settings).Error; err != nil {
return false, err
}
values := make(map[string]string, len(settings))
for _, setting := range settings {
values[setting.Key] = setting.Value
}
return strings.EqualFold(strings.TrimSpace(values[statusKey]), constant.StatusEnable) &&
strings.TrimSpace(values[accountIDKey]) == strconv.FormatUint(uint64(accountID), 10), nil
} }
func loadOpenclawAgentByID(agentID uint) (*model.Agent, error) { func loadOpenclawAgentByID(agentID uint) (*model.Agent, error) {
@@ -59,19 +98,27 @@ func ensureContainerRunning(containerName string) error {
return nil return nil
} }
func resolveAgentAccountInput(provider, apiKey, baseURL string) (resolvedAgentAccountInput, error) { func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey) resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedBaseURL := strings.TrimSpace(baseURL) resolvedAPIType := strings.TrimSpace(apiType)
if resolvedBaseURL == "" { resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
if requiresInitialAgentAccountModels(provider) { if err != nil {
return resolvedAgentAccountInput{}, err
}
resolvedBaseURL, err := providercatalog.ResolveBaseURL(provider, resolvedAPIType, baseURL)
if err != nil {
if strings.Contains(err.Error(), "base url is required") {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentBaseURLRequired") return resolvedAgentAccountInput{}, buserr.New("ErrAgentBaseURLRequired")
} }
if defaultURL, ok := providercatalog.DefaultBaseURL(provider); ok { return resolvedAgentAccountInput{}, err
resolvedBaseURL = defaultURL
}
} }
if !providercatalog.SkipVerification(provider) { modelID = strings.TrimSpace(modelID)
if err := providercatalog.VerifyAccount(provider, resolvedBaseURL, resolvedAPIKey); err != nil { if modelID == "" {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
}
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
if validateAvailability && (imageAPI || providercatalog.IsEmbeddingAPIType(resolvedAPIType) || !providercatalog.SkipVerification(provider)) {
if err := providercatalog.VerifyAccount(provider, resolvedAPIType, resolvedAuthMode, resolvedBaseURL, resolvedAPIKey, modelID); err != nil {
return resolvedAgentAccountInput{}, err return resolvedAgentAccountInput{}, err
} }
} }
@@ -79,6 +126,8 @@ func resolveAgentAccountInput(provider, apiKey, baseURL string) (resolvedAgentAc
Provider: provider, Provider: provider,
APIKey: resolvedAPIKey, APIKey: resolvedAPIKey,
BaseURL: resolvedBaseURL, BaseURL: resolvedBaseURL,
APIType: resolvedAPIType,
AuthMode: resolvedAuthMode,
}, nil }, nil
} }
@@ -335,26 +384,24 @@ func setOtherConfig(conf map[string]interface{}, config dto.AgentOtherConfig) {
func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map[string]interface{}) dto.AgentItem { func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map[string]interface{}) dto.AgentItem {
agentType := agent.AgentType agentType := agent.AgentType
item := dto.AgentItem{ item := dto.AgentItem{
ID: agent.ID, ID: agent.ID,
Name: agent.Name, Name: agent.Name,
Remark: agent.Remark, Remark: agent.Remark,
AgentType: agentType, AgentType: agentType,
Provider: agent.Provider, Provider: agent.Provider,
ProviderName: providercatalog.DisplayName(agent.Provider), ProviderName: localizedAgentProviderName(agent.Provider),
Model: agent.Model, Model: agent.Model,
APIType: agent.APIType, APIType: agent.APIType,
MaxTokens: agent.MaxTokens, BaseURL: agent.BaseURL,
ContextWindow: agent.ContextWindow, APIKey: maskKey(agent.APIKey),
BaseURL: agent.BaseURL, Token: agent.Token,
APIKey: maskKey(agent.APIKey), Status: agent.Status,
Token: agent.Token, Message: agent.Message,
Status: agent.Status, AppInstallID: agent.AppInstallID,
Message: agent.Message, WebsiteID: agent.WebsiteID,
AppInstallID: agent.AppInstallID, AccountID: agent.AccountID,
WebsiteID: agent.WebsiteID, ConfigPath: agent.ConfigPath,
AccountID: agent.AccountID, CreatedAt: agent.CreatedAt,
ConfigPath: agent.ConfigPath,
CreatedAt: agent.CreatedAt,
} }
if appInstall != nil && appInstall.ID > 0 { if appInstall != nil && appInstall.ID > 0 {
item.Container = appInstall.ContainerName item.Container = appInstall.ContainerName
@@ -376,10 +423,24 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
item.BridgePort = toInt(bridge) item.BridgePort = toInt(bridge)
} }
} }
if _, _, ok := agentDashboardAuthEnvKeys(agentType); ok {
auth := readAgentDashboardAuthFromInstall(appInstall, agentType)
item.DashboardUsername = auth.Username
item.DashboardPassword = auth.Password
}
} }
return item return item
} }
func localizedAgentProviderName(provider string) string {
if key := providercatalog.DisplayNameKey(provider); key != "" {
if name := strings.TrimSpace(i18n.GetMsgByKey(key)); name != "" {
return name
}
}
return providercatalog.DisplayName(provider)
}
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
} }
@@ -677,13 +738,11 @@ type modelProvider struct {
} }
type modelEntry struct { type modelEntry struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Reasoning bool `json:"reasoning"` Input []string `json:"input,omitempty"`
Input []string `json:"input"` ContextWindow int `json:"contextWindow,omitempty"`
ContextWindow int `json:"contextWindow"` MaxTokens int `json:"maxTokens,omitempty"`
MaxTokens int `json:"maxTokens"`
Cost modelCost `json:"cost"`
} }
func requiresOpenclawProviderModels(provider string) bool { func requiresOpenclawProviderModels(provider string) bool {
@@ -703,13 +762,6 @@ func applyOpenclawModelsConfig(conf map[string]interface{}, models *modelsConfig
return nil return nil
} }
type modelCost struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
CacheRead float64 `json:"cacheRead"`
CacheWrite float64 `json:"cacheWrite"`
}
type browserConfig struct { type browserConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ExecutablePath string `json:"executablePath"` ExecutablePath string `json:"executablePath"`
@@ -718,7 +770,7 @@ type browserConfig struct {
DefaultProfile string `json:"defaultProfile"` DefaultProfile string `json:"defaultProfile"`
} }
func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName, token string, allowedOrigins []string, fallbacks []string) error { func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName, token string, allowedOrigins []string, fallbacks []string, metadata []dto.AgentModelMetadata) error {
if strings.TrimSpace(confDir) == "" { if strings.TrimSpace(confDir) == "" {
return fmt.Errorf("config dir is required") return fmt.Errorf("config dir is required")
} }
@@ -803,6 +855,7 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
} }
conf = initial conf = initial
} else { } else {
preserveOpenclawModelMetadata(conf, cfg.Models)
if err := applyOpenclawModelsConfig(conf, cfg.Models); err != nil { if err := applyOpenclawModelsConfig(conf, cfg.Models); err != nil {
return err return err
} }
@@ -857,16 +910,159 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
if allowedOrigins != nil { if allowedOrigins != nil {
setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins}) setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins})
} }
if err := applyOpenclawModelMetadata(conf, account, metadata); err != nil {
return err
}
if err := writeOpenclawConfigRaw(configPath, conf); err != nil { if err := writeOpenclawConfigRaw(configPath, conf); err != nil {
return err return err
} }
envPath := path.Join(confDir, ".env") envMap := map[string]string{
lines := []string{fmt.Sprintf("OPENCLAW_GATEWAY_TOKEN=%s", token)} "OPENCLAW_GATEWAY_TOKEN": token,
if envKey := providercatalog.EnvKey(account.Provider); envKey != "" && account.APIKey != "" {
lines = append(lines, fmt.Sprintf("%s=%s", envKey, account.APIKey))
} }
content := strings.Join(lines, "\n") + "\n" order := []string{"OPENCLAW_GATEWAY_TOKEN"}
return fileOp.SaveFile(envPath, content, 0600) if envKey := providercatalog.EnvKey(account.Provider); envKey != "" && account.APIKey != "" {
envMap[envKey] = account.APIKey
order = append(order, envKey)
}
return writeAgentEnvMap(path.Join(confDir, ".env"), envMap, order)
}
func readOpenclawModelsConfig(conf map[string]interface{}) *modelsConfig {
raw, ok := conf["models"]
if !ok {
return nil
}
payload, err := json.Marshal(raw)
if err != nil {
return nil
}
var models modelsConfig
if err := json.Unmarshal(payload, &models); err != nil {
return nil
}
return &models
}
func preserveOpenclawModelMetadata(conf map[string]interface{}, next *modelsConfig) {
current := readOpenclawModelsConfig(conf)
if current == nil || next == nil {
return
}
for providerID, nextProvider := range next.Providers {
currentProvider, ok := current.Providers[providerID]
if !ok {
continue
}
byID := make(map[string]modelEntry, len(currentProvider.Models))
for _, entry := range currentProvider.Models {
byID[entry.ID] = entry
}
for index := range nextProvider.Models {
currentEntry, ok := byID[nextProvider.Models[index].ID]
if !ok {
continue
}
nextProvider.Models[index].Input = currentEntry.Input
nextProvider.Models[index].ContextWindow = currentEntry.ContextWindow
nextProvider.Models[index].MaxTokens = currentEntry.MaxTokens
}
next.Providers[providerID] = nextProvider
}
}
func extractOpenclawModelMetadata(conf map[string]interface{}, account *model.AgentAccount, accountModels []dto.AgentAccountModel) []dto.AgentModelMetadata {
result := make([]dto.AgentModelMetadata, 0, len(accountModels))
configured := readOpenclawModelsConfig(conf)
for _, item := range accountModels {
_, inferred, providerID, _, err := buildOpenclawAccountModelConfig(account, item)
if err != nil {
continue
}
metadata := dto.AgentModelMetadata{Model: item.ID, InputMode: "auto"}
if configured != nil {
for _, entry := range configured.Providers[providerID].Models {
if entry.ID != inferred.ID {
continue
}
metadata.ContextWindow = entry.ContextWindow
metadata.MaxTokens = entry.MaxTokens
if len(entry.Input) > 0 && !slices.Equal(entry.Input, inferred.Input) {
if slices.Contains(entry.Input, "image") {
metadata.InputMode = "image"
} else {
metadata.InputMode = "text"
}
}
break
}
}
result = append(result, metadata)
}
return result
}
func applyOpenclawModelMetadata(conf map[string]interface{}, account *model.AgentAccount, requested []dto.AgentModelMetadata) error {
if len(requested) == 0 {
return nil
}
configured := readOpenclawModelsConfig(conf)
if configured == nil {
return fmt.Errorf("model metadata is not supported for provider %s", account.Provider)
}
accountModels, err := loadAgentAccountModels(account)
if err != nil {
return err
}
available := make(map[string]dto.AgentAccountModel, len(accountModels))
for _, item := range accountModels {
available[item.ID] = item
}
seen := make(map[string]struct{}, len(requested))
for _, metadata := range requested {
item, ok := available[metadata.Model]
if !ok {
return buserr.New("ErrAgentModelNotInAccount")
}
if _, ok := seen[metadata.Model]; ok {
return fmt.Errorf("duplicate model metadata: %s", metadata.Model)
}
seen[metadata.Model] = struct{}{}
_, inferred, providerID, _, err := buildOpenclawAccountModelConfig(account, item)
if err != nil {
return err
}
provider := configured.Providers[providerID]
found := false
for index := range provider.Models {
if provider.Models[index].ID != inferred.ID {
continue
}
found = true
provider.Models[index].ContextWindow = metadata.ContextWindow
provider.Models[index].MaxTokens = metadata.MaxTokens
switch metadata.InputMode {
case "auto":
provider.Models[index].Input = inferred.Input
case "text":
provider.Models[index].Input = []string{"text"}
case "image":
provider.Models[index].Input = []string{"text", "image"}
default:
return fmt.Errorf("unsupported model input mode: %s", metadata.InputMode)
}
break
}
if !found {
return buserr.New("ErrAgentModelNotInAccount")
}
configured.Providers[providerID] = provider
}
modelsMap, err := structToMap(configured)
if err != nil {
return err
}
conf["models"] = modelsMap
return nil
} }
func resolveOpenclawFallbackModels(account *model.AgentAccount, primaryModel string, fallbackIDs []string) ([]string, error) { func resolveOpenclawFallbackModels(account *model.AgentAccount, primaryModel string, fallbackIDs []string) ([]string, error) {
@@ -990,7 +1186,7 @@ func prepareOpenclawInstallFiles(appInstall *model.AppInstall, account *model.Ag
return fmt.Errorf("app install is required") return fmt.Errorf("app install is required")
} }
confDir := path.Join(appInstall.GetPath(), "data", "conf") confDir := path.Join(appInstall.GetPath(), "data", "conf")
if err := writeOpenclawConfig(confDir, account, modelName, token, allowedOrigins, nil); err != nil { if err := writeOpenclawConfig(confDir, account, modelName, token, allowedOrigins, nil, nil); err != nil {
return err return err
} }
dataDir := path.Join(appInstall.GetPath(), "data") dataDir := path.Join(appInstall.GetPath(), "data")
@@ -1053,7 +1249,7 @@ func buildOpenclawModelsFromAccount(account *model.AgentAccount, selectedModel s
} }
func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.AgentAccountModel) (string, modelEntry, string, modelProvider, error) { func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.AgentAccountModel) (string, modelEntry, string, modelProvider, error) {
providerPatch, err := providercatalog.BuildOpenClawProviderPatch(account.Provider, model.ID, account.APIType, account.BaseURL, account.APIKey) providerPatch, err := providercatalog.BuildOpenClawProviderPatch(account.Provider, model.ID, account.APIType, account.AuthMode, account.BaseURL, account.APIKey)
if err != nil { if err != nil {
return "", modelEntry{}, "", modelProvider{}, err return "", modelEntry{}, "", modelProvider{}, err
} }
@@ -1065,28 +1261,24 @@ func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.Agen
}, nil }, nil
} }
var openclawVisionModelPattern = regexp.MustCompile(`(?i)(\b(gpt-4o|gpt-4\.1|gpt-[5-9]|o[134])\b|\bclaude-(3|4|sonnet|opus|haiku)\b|\bgemini\b|\b(qwen[\w.-]*-?vl|qwen-vl|qwen3\.[5-9]-plus)\b|\b(kimi-k2\.(5|6)|kimi-k2\.7-code|minimax-m3)\b|\b(vision|llava|pixtral|internvl|mllama|minicpm-v|glm-4v|omni)\b|(^|[-_/])vl([-_/]|$))`)
func buildOpenclawModelEntry(modelID string, model dto.AgentAccountModel) modelEntry { func buildOpenclawModelEntry(modelID string, model dto.AgentAccountModel) modelEntry {
name := strings.TrimSpace(model.Name) name := strings.TrimSpace(model.Name)
if name == "" { if name == "" {
name = strings.TrimSpace(modelID) name = strings.TrimSpace(modelID)
} }
return modelEntry{ entry := modelEntry{ID: strings.TrimSpace(modelID), Name: name}
ID: strings.TrimSpace(modelID), if openclawVisionModelPattern.MatchString(modelID) {
Name: name, entry.Input = []string{"text", "image"}
Reasoning: model.Reasoning,
Input: sanitizeAgentAccountModelInputs(model.Input),
ContextWindow: model.ContextWindow,
MaxTokens: model.MaxTokens,
Cost: modelCost{},
} }
return entry
} }
type openclawAccountModelRuntime struct { type openclawAccountModelRuntime struct {
StoredModel string StoredModel string
PrimaryModel string PrimaryModel string
APIType string APIType string
MaxTokens int
ContextWindow int
} }
func buildOpenclawAccountModelRuntime(account *model.AgentAccount, model dto.AgentAccountModel) (openclawAccountModelRuntime, error) { func buildOpenclawAccountModelRuntime(account *model.AgentAccount, model dto.AgentAccountModel) (openclawAccountModelRuntime, error) {
@@ -1095,11 +1287,9 @@ func buildOpenclawAccountModelRuntime(account *model.AgentAccount, model dto.Age
return openclawAccountModelRuntime{}, err return openclawAccountModelRuntime{}, err
} }
return openclawAccountModelRuntime{ return openclawAccountModelRuntime{
StoredModel: model.ID, StoredModel: model.ID,
PrimaryModel: primaryModel, PrimaryModel: primaryModel,
APIType: account.APIType, APIType: account.APIType,
MaxTokens: model.MaxTokens,
ContextWindow: model.ContextWindow,
}, nil }, nil
} }
@@ -1119,35 +1309,38 @@ func buildInitialAgentAccountModels(account *model.AgentAccount, requested []dto
if account == nil { if account == nil {
return nil, fmt.Errorf("account is required") return nil, fmt.Errorf("account is required")
} }
if requiresInitialAgentAccountModels(account.Provider) && len(requested) > 1 { if account.Provider != "custom" && requiresInitialAgentAccountModels(account.Provider) && len(requested) > 1 {
return nil, buserr.New("ErrAgentAccountSingleInitialModel") return nil, buserr.New("ErrAgentAccountSingleInitialModel")
} }
if len(requested) > 0 { if len(requested) > 0 {
models := make([]dto.AgentAccountModel, 0, len(requested)) return normalizeAgentAccountModels(account, requested)
for _, item := range requested {
models = append(models, cloneAgentAccountModel(item))
}
return models, nil
} }
meta, ok := providercatalog.Get(account.Provider) defaultModels := providercatalog.DefaultModels(account.Provider, account.APIType)
if !ok || len(meta.Models) == 0 { if len(defaultModels) == 0 {
if requiresInitialAgentAccountModels(account.Provider) { if requiresInitialAgentAccountModels(account.Provider) {
return nil, buserr.New("ErrAgentAccountModelsRequired") return nil, buserr.New("ErrAgentAccountModelsRequired")
} }
return nil, nil return nil, nil
} }
requested = make([]dto.AgentAccountModel, 0, len(meta.Models)) requested = make([]dto.AgentAccountModel, 0, len(defaultModels))
for _, item := range meta.Models { for _, item := range defaultModels {
requested = append(requested, dto.AgentAccountModel{ requested = append(requested, dto.AgentAccountModel{
ID: item.ID, ID: item.ID,
Name: item.Name, Name: item.Name,
ContextWindow: item.ContextWindow,
MaxTokens: item.MaxTokens,
Reasoning: item.Reasoning,
Input: append([]string(nil), item.Input...),
}) })
} }
return requested, nil return normalizeAgentAccountModels(account, requested)
}
func buildDiscoveredAgentAccountModels(modelIDs []string) []dto.AgentAccountModel {
models := make([]dto.AgentAccountModel, 0, len(modelIDs))
for _, modelID := range modelIDs {
models = append(models, dto.AgentAccountModel{
ID: modelID,
Name: modelID,
})
}
return models
} }
func compactPersistedAgentAccountModelSortOrder(accountID uint) error { func compactPersistedAgentAccountModelSortOrder(accountID uint) error {
@@ -1175,18 +1368,6 @@ func loadAgentAccountModels(account *model.AgentAccount) ([]dto.AgentAccountMode
return listPersistedAgentAccountModels(account.ID) return listPersistedAgentAccountModels(account.ID)
} }
func cloneAgentAccountModel(model dto.AgentAccountModel) dto.AgentAccountModel {
return dto.AgentAccountModel{
RecordID: model.RecordID,
ID: model.ID,
Name: model.Name,
ContextWindow: model.ContextWindow,
MaxTokens: model.MaxTokens,
Reasoning: model.Reasoning,
Input: append([]string(nil), model.Input...),
}
}
func MergeCatalogAgentAccountModelsForMigration(account *model.AgentAccount, existing []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) { func MergeCatalogAgentAccountModelsForMigration(account *model.AgentAccount, existing []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) {
if account == nil { if account == nil {
return nil, fmt.Errorf("account is required") return nil, fmt.Errorf("account is required")
@@ -1210,12 +1391,8 @@ func MergeCatalogAgentAccountModelsForMigration(account *model.AgentAccount, exi
continue continue
} }
requested = append(requested, dto.AgentAccountModel{ requested = append(requested, dto.AgentAccountModel{
ID: item.ID, ID: item.ID,
Name: item.Name, Name: item.Name,
ContextWindow: item.ContextWindow,
MaxTokens: item.MaxTokens,
Reasoning: item.Reasoning,
Input: append([]string(nil), item.Input...),
}) })
} }
if len(requested) == len(existing) { if len(requested) == len(existing) {
@@ -1234,18 +1411,10 @@ func listPersistedAgentAccountModels(accountID uint) ([]dto.AgentAccountModel, e
} }
result := make([]dto.AgentAccountModel, 0, len(rows)) result := make([]dto.AgentAccountModel, 0, len(rows))
for _, row := range rows { for _, row := range rows {
inputs := []string{}
if strings.TrimSpace(row.Input) != "" {
_ = json.Unmarshal([]byte(row.Input), &inputs)
}
result = append(result, dto.AgentAccountModel{ result = append(result, dto.AgentAccountModel{
RecordID: row.ID, RecordID: row.ID,
ID: strings.TrimSpace(row.Model), ID: strings.TrimSpace(row.Model),
Name: strings.TrimSpace(row.Name), Name: strings.TrimSpace(row.Name),
ContextWindow: row.ContextWindow,
MaxTokens: row.MaxTokens,
Reasoning: row.Reasoning,
Input: sanitizeAgentAccountModelInputs(inputs),
}) })
} }
return result, nil return result, nil
@@ -1256,19 +1425,11 @@ func replacePersistedAgentAccountModelsWithTx(tx *gorm.DB, accountID uint, model
return err return err
} }
for index, item := range models { for index, item := range models {
inputPayload, err := json.Marshal(sanitizeAgentAccountModelInputs(item.Input))
if err != nil {
return err
}
record := &model.AgentAccountModel{ record := &model.AgentAccountModel{
AccountID: accountID, AccountID: accountID,
Model: strings.TrimSpace(item.ID), Model: strings.TrimSpace(item.ID),
Name: strings.TrimSpace(item.Name), Name: strings.TrimSpace(item.Name),
ContextWindow: item.ContextWindow, SortOrder: index + 1,
MaxTokens: item.MaxTokens,
Reasoning: item.Reasoning,
Input: string(inputPayload),
SortOrder: index + 1,
} }
if err := tx.Create(record).Error; err != nil { if err := tx.Create(record).Error; err != nil {
return err return err
@@ -1309,125 +1470,34 @@ func normalizeAgentAccountModel(account *model.AgentAccount, model dto.AgentAcco
if modelID == "" { if modelID == "" {
return dto.AgentAccountModel{}, fmt.Errorf("model is required") return dto.AgentAccountModel{}, fmt.Errorf("model is required")
} }
modelID = providercatalog.NormalizeModelID(account.Provider, modelID)
name := strings.TrimSpace(model.Name) name := strings.TrimSpace(model.Name)
if name == "" { if name == "" {
name = modelID name = modelID
} }
inputs := sanitizeAgentAccountModelInputs(model.Input)
return dto.AgentAccountModel{ return dto.AgentAccountModel{
ID: normalizeAgentAccountModelID(account.Provider, modelID), ID: modelID,
Name: name, Name: name,
ContextWindow: model.ContextWindow,
MaxTokens: model.MaxTokens,
Reasoning: model.Reasoning,
Input: inputs,
}, nil }, nil
} }
func normalizeAgentAccountModelID(provider, requestedID string) string {
switch provider {
case "custom", "vllm":
return normalizeCustomModel(requestedID)
case "ollama":
target := strings.TrimSpace(requestedID)
if strings.HasPrefix(target, "ollama/") {
return target
}
target = strings.TrimLeft(strings.TrimSpace(target), "/")
if target == "" {
return ""
}
return "ollama/" + target
default:
target := strings.TrimSpace(requestedID)
if target == "" {
return ""
}
prefix := poolModelPrefix(provider)
if strings.Contains(target, "/") {
parts := strings.SplitN(target, "/", 2)
targetPrefix := parts[0]
targetModel := strings.TrimSpace(parts[1])
if targetModel == "" {
return strings.TrimSpace(target)
}
for _, item := range supportedProviderModelPrefixes(provider) {
if item == targetPrefix {
if prefix != "" {
return prefix + "/" + targetModel
}
return strings.TrimSpace(target)
}
}
return strings.TrimSpace(target)
}
target = strings.TrimLeft(strings.TrimSpace(target), "/")
if prefix == "" {
return target
}
return prefix + "/" + target
}
}
func sanitizeAgentAccountModelInputs(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
normalized := value
if normalized != "text" && normalized != "image" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
if len(result) == 0 {
return []string{"text"}
}
return result
}
func requiresInitialAgentAccountModels(provider string) bool { func requiresInitialAgentAccountModels(provider string) bool {
switch provider { switch provider {
case "custom", "vllm", "ollama": case "custom", "vllm", "ollama", "llmman":
return true return true
default: default:
return false return false
} }
} }
func normalizeComparableProviderModelID(provider, modelID string) string {
target := strings.TrimSpace(modelID)
if target == "" {
return ""
}
if !strings.Contains(target, "/") {
return target
}
parts := strings.SplitN(target, "/", 2)
prefix := parts[0]
model := strings.TrimSpace(parts[1])
if model == "" {
return target
}
for _, item := range supportedProviderModelPrefixes(provider) {
if item == prefix {
return model
}
}
return target
}
func sameProviderModelID(provider, left, right string) bool { func sameProviderModelID(provider, left, right string) bool {
leftTrimmed := strings.TrimSpace(left) leftTrimmed := strings.TrimSpace(left)
rightTrimmed := strings.TrimSpace(right) rightTrimmed := strings.TrimSpace(right)
if leftTrimmed == rightTrimmed { if leftTrimmed == rightTrimmed {
return true return true
} }
leftComparable := normalizeComparableProviderModelID(provider, leftTrimmed) leftComparable := providercatalog.NormalizeModelID(provider, leftTrimmed)
rightComparable := normalizeComparableProviderModelID(provider, rightTrimmed) rightComparable := providercatalog.NormalizeModelID(provider, rightTrimmed)
return leftComparable != "" && leftComparable == rightComparable return leftComparable != "" && leftComparable == rightComparable
} }
@@ -1448,6 +1518,20 @@ func requireAgentAccountModelForProvider(provider string, models []dto.AgentAcco
return selectedAccountModel, nil return selectedAccountModel, nil
} }
func resolveAgentAccountVerifyModel(provider, requested string, models []dto.AgentAccountModel) (string, error) {
if len(models) == 0 {
return "", buserr.New("ErrAgentAccountModelsRequired")
}
if strings.TrimSpace(requested) == "" {
return models[0].ID, nil
}
selected, ok := findAgentAccountModelForProvider(provider, models, requested)
if !ok {
return "", buserr.New("ErrAgentModelNotInAccount")
}
return selected.ID, nil
}
func ensureAccountModelsNotBound(account *model.AgentAccount, models []dto.AgentAccountModel) error { func ensureAccountModelsNotBound(account *model.AgentAccount, models []dto.AgentAccountModel) error {
if account == nil || account.ID == 0 { if account == nil || account.ID == 0 {
return nil return nil
@@ -1524,6 +1608,122 @@ func readInstallEnv(envStr string) map[string]interface{} {
return data return data
} }
const (
hermesDashboardUsernameEnvKey = "HERMES_DASHBOARD_USERNAME"
hermesDashboardPasswordEnvKey = "HERMES_DASHBOARD_PASSWORD"
qwenPawAuthEnabledEnvKey = "QWENPAW_AUTH_ENABLED"
qwenPawAuthUsernameEnvKey = "QWENPAW_AUTH_USERNAME"
qwenPawAuthPasswordEnvKey = "QWENPAW_AUTH_PASSWORD"
)
type agentDashboardAuth struct {
Username string
Password string
}
func normalizeAgentDashboardAuth(username, password string) agentDashboardAuth {
auth := agentDashboardAuth{
Username: strings.TrimSpace(username),
Password: strings.TrimSpace(password),
}
if auth.Username == "" {
auth.Username = "admin"
}
if auth.Password == "" {
auth.Password = common.RandStr(8)
}
return auth
}
func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
switch agentType {
case constant.AppHermesAgent:
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
case constant.AppCopaw:
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
default:
return "", "", false
}
}
func writeAgentDashboardAuthEnv(envPath, agentType string, auth agentDashboardAuth, overwrite bool) error {
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
if !ok {
return fmt.Errorf("dashboard auth is not supported for %s", agentType)
}
values := map[string]string{
usernameKey: auth.Username,
passwordKey: auth.Password,
}
order := []string{usernameKey, passwordKey}
if agentType == constant.AppCopaw {
values[qwenPawAuthEnabledEnvKey] = "true"
order = append([]string{qwenPawAuthEnabledEnvKey}, order...)
}
return upsertAgentEnv(envPath, values, order, overwrite)
}
func readAgentDashboardAuthEnv(envPath, agentType string) (agentDashboardAuth, error) {
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
if !ok {
return agentDashboardAuth{}, fmt.Errorf("dashboard auth is not supported for %s", agentType)
}
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return agentDashboardAuth{}, err
}
return agentDashboardAuth{
Username: strings.TrimSpace(envMap[usernameKey]),
Password: strings.TrimSpace(envMap[passwordKey]),
}, nil
}
func readAgentDashboardAuthFromInstall(appInstall *model.AppInstall, agentType string) agentDashboardAuth {
if appInstall == nil || appInstall.ID == 0 {
return agentDashboardAuth{}
}
auth, err := readAgentDashboardAuthEnv(appInstall.GetEnvPath(), agentType)
if err != nil {
return agentDashboardAuth{}
}
return auth
}
func readAgentEnvMap(envPath string) (map[string]string, error) {
fileOp := files.NewFileOp()
if !fileOp.Stat(envPath) {
return map[string]string{}, nil
}
envMap, err := godotenv.Read(envPath)
if err != nil {
return nil, err
}
return envMap, nil
}
func writeAgentEnvMap(envPath string, envMap map[string]string, order []string) error {
if len(envMap) == 0 {
return files.NewFileOp().SaveFile(envPath, "", 0600)
}
return agentenv.WriteWithOrder(envMap, envPath, order)
}
func upsertAgentEnv(envPath string, values map[string]string, order []string, overwrite bool) error {
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
for key, value := range values {
if key == "" {
continue
}
if overwrite || strings.TrimSpace(envMap[key]) == "" {
envMap[key] = value
}
}
return writeAgentEnvMap(envPath, envMap, order)
}
func maskKey(value string) string { func maskKey(value string) string {
trim := strings.TrimSpace(value) trim := strings.TrimSpace(value)
if len(trim) <= 6 { if len(trim) <= 6 {
@@ -1551,57 +1751,6 @@ func toInt(value interface{}) int {
} }
} }
func normalizeCustomModel(modelName string) string {
trim := strings.TrimSpace(modelName)
trim = strings.TrimLeft(trim, "/")
if parts := strings.SplitN(trim, "/", 2); len(parts) == 2 {
if strings.EqualFold(parts[0], "custom") {
return strings.TrimLeft(strings.TrimSpace(parts[1]), "/")
}
}
return trim
}
func runtimeProviderModelPrefix(provider string) string {
switch provider {
case "gemini":
return "google"
case "kimi":
return "moonshot"
default:
return provider
}
}
func poolModelPrefix(provider string) string {
meta, ok := providercatalog.Get(provider)
if ok && len(meta.Models) > 0 {
parts := strings.SplitN(strings.TrimSpace(meta.Models[0].ID), "/", 2)
if len(parts) == 2 && strings.TrimSpace(parts[0]) != "" {
return parts[0]
}
}
return provider
}
func supportedProviderModelPrefixes(provider string) []string {
values := []string{poolModelPrefix(provider), runtimeProviderModelPrefix(provider)}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
target := value
if target == "" {
continue
}
if _, ok := seen[target]; ok {
continue
}
seen[target] = struct{}{}
result = append(result, target)
}
return result
}
func generateToken() string { func generateToken() string {
bytes := make([]byte, 24) bytes := make([]byte, 24)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {
+1 -5
View File
@@ -162,7 +162,7 @@ func fillAgentWebsiteItems(items []dto.AgentItem, explicitWebsiteMap map[uint]mo
} }
} }
func uniqueDeploymentWebsiteMapByAppInstall(websites []model.Website) map[uint]model.Website { func UniqueDeploymentWebsiteMapByAppInstall(websites []model.Website) map[uint]model.Website {
websiteMap := make(map[uint]model.Website) websiteMap := make(map[uint]model.Website)
duplicateAppInstallIDs := make(map[uint]struct{}) duplicateAppInstallIDs := make(map[uint]struct{})
for _, website := range websites { for _, website := range websites {
@@ -182,10 +182,6 @@ func uniqueDeploymentWebsiteMapByAppInstall(websites []model.Website) map[uint]m
return websiteMap return websiteMap
} }
func UniqueDeploymentWebsiteMapForMigration(websites []model.Website) map[uint]model.Website {
return uniqueDeploymentWebsiteMapByAppInstall(websites)
}
func uniqueUintList(items []uint) []uint { func uniqueUintList(items []uint) []uint {
itemMap := make(map[uint]struct{}, len(items)) itemMap := make(map[uint]struct{}, len(items))
uniq := make([]uint, 0, len(items)) uniq := make([]uint, 0, len(items))
+139
View File
@@ -0,0 +1,139 @@
package service
import (
"encoding/json"
"strings"
"github.com/1Panel-dev/1Panel/agent/utils/common"
)
const (
vllmAppKeyForUpgrade = "vllm"
vllmImageEnvKey = "IMAGE"
vllmImageTypeNvidia = "nvidia"
vllmImageTypeIntel = "intel"
vllmImageTypeAscend = "ascend"
)
func resolveVllmVersionFamily(version, image string) string {
normalizedVersion := strings.ToLower(strings.TrimSpace(version))
if strings.HasPrefix(normalizedVersion, vllmImageTypeIntel+"-") {
return vllmImageTypeIntel
}
if strings.HasPrefix(normalizedVersion, vllmImageTypeAscend+"-") {
return vllmImageTypeAscend
}
if strings.HasPrefix(normalizedVersion, vllmImageTypeNvidia+"-") {
return vllmImageTypeNvidia
}
normalizedImage := strings.ToLower(strings.TrimSpace(image))
if strings.Contains(normalizedImage, "intel/") || strings.Contains(normalizedImage, "llm-scaler-vllm") {
return vllmImageTypeIntel
}
if strings.Contains(normalizedImage, "ascend/") || strings.Contains(normalizedImage, "vllm-ascend") {
return vllmImageTypeAscend
}
return vllmImageTypeNvidia
}
func trimVllmVersionFamily(version string) string {
trimmed := strings.TrimSpace(version)
normalized := strings.ToLower(trimmed)
for _, family := range []string{vllmImageTypeNvidia, vllmImageTypeIntel, vllmImageTypeAscend} {
prefix := family + "-"
if strings.HasPrefix(normalized, prefix) {
return strings.TrimSpace(trimmed[len(prefix):])
}
}
return trimmed
}
func buildDefaultVllmImageByVersion(version string) string {
tag := trimVllmVersionFamily(version)
family := resolveVllmVersionFamily(version, "")
if family == vllmImageTypeIntel {
return "intel/llm-scaler-vllm:" + tag
}
if tag != "" && !strings.HasPrefix(strings.ToLower(tag), "v") {
tag = "v" + tag
}
if family == vllmImageTypeAscend {
return "quay.io/ascend/vllm-ascend:" + tag
}
return "vllm/vllm-openai:" + tag
}
func isVllmUpgradeVersionAllowed(currentVersion, targetVersion, currentImage string) bool {
currentFamily := resolveVllmVersionFamily(currentVersion, currentImage)
targetFamily := resolveVllmVersionFamily(targetVersion, "")
return currentFamily == targetFamily
}
func hasVllmVersionFamilyPrefix(version string) bool {
normalized := strings.ToLower(strings.TrimSpace(version))
return strings.HasPrefix(normalized, vllmImageTypeNvidia+"-") ||
strings.HasPrefix(normalized, vllmImageTypeIntel+"-") ||
strings.HasPrefix(normalized, vllmImageTypeAscend+"-")
}
func isVllmUpgradeCandidate(currentVersion, targetVersion, currentImage string) bool {
if strings.TrimSpace(currentVersion) == strings.TrimSpace(targetVersion) {
return false
}
if !isVllmUpgradeVersionAllowed(currentVersion, targetVersion, currentImage) {
return false
}
if common.CompareVersion(targetVersion, currentVersion) {
return true
}
return !hasVllmVersionFamilyPrefix(currentVersion) &&
resolveVllmVersionFamily(targetVersion, "") == vllmImageTypeNvidia &&
trimVllmVersionFamily(currentVersion) == trimVllmVersionFamily(targetVersion)
}
func buildVllmUpgradeImage(currentImage, currentVersion, targetVersion string) string {
trimmedImage := strings.TrimSpace(currentImage)
if trimmedImage == "" || trimmedImage == buildDefaultVllmImageByVersion(currentVersion) {
return buildDefaultVllmImageByVersion(targetVersion)
}
return trimmedImage
}
func loadVllmImageFromEnv(raw string) string {
envs := make(map[string]interface{})
if strings.TrimSpace(raw) == "" {
return ""
}
if err := json.Unmarshal([]byte(raw), &envs); err != nil {
return ""
}
if image, ok := envs[vllmImageEnvKey].(string); ok {
return strings.TrimSpace(image)
}
return ""
}
func setVllmImageInEnvContent(content []byte, image string) []byte {
normalizedImage := strings.TrimSpace(image)
if normalizedImage == "" {
return content
}
lines := strings.Split(string(content), "\n")
replaced := false
for index, line := range lines {
if strings.HasPrefix(line, vllmImageEnvKey+"=") {
lines[index] = vllmImageEnvKey + "=" + normalizedImage
replaced = true
break
}
}
if !replaced {
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines[len(lines)-1] = vllmImageEnvKey + "=" + normalizedImage
lines = append(lines, "")
} else {
lines = append(lines, vllmImageEnvKey+"="+normalizedImage)
}
}
return []byte(strings.Join(lines, "\n"))
}
+333 -30
View File
@@ -17,10 +17,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"
alertconfig "github.com/1Panel-dev/1Panel/agent/utils/alert_config"
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/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/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/disk"
) )
@@ -34,6 +37,28 @@ var communityAlertMethodTypeNames = map[string]string{
constant.SMS: "SMS", constant.SMS: "SMS",
} }
var legacyAlertMethodTypeMap = map[string]string{
"mail": constant.Email,
constant.Email: constant.Email,
constant.SMS: constant.SMS,
constant.Bark: constant.Bark,
constant.WeChat: constant.WeCom,
constant.WeCom: constant.WeCom,
constant.DingTalk: constant.DingTalk,
constant.FeiShu: constant.FeiShu,
constant.Custom: constant.Custom,
}
var supportedAlertMethodTypes = map[string]struct{}{
constant.Email: {},
constant.SMS: {},
constant.Bark: {},
constant.WeCom: {},
constant.DingTalk: {},
constant.FeiShu: {},
constant.Custom: {},
}
type IAlertService interface { type IAlertService interface {
PageAlert(req dto.AlertSearch) (int64, []dto.AlertDTO, error) PageAlert(req dto.AlertSearch) (int64, []dto.AlertDTO, error)
GetAlerts() ([]dto.AlertDTO, error) GetAlerts() ([]dto.AlertDTO, error)
@@ -53,8 +78,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
DeleteAlertConfig(id uint) error DeleteAlertConfig(id uint) error
TestAlertConfig(req dto.AlertConfigTest) (bool, error) TestAlertConfig(req dto.AlertConfigTest) (bool, error)
TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error)
} }
func NewIAlertService() IAlertService { func NewIAlertService() IAlertService {
@@ -180,9 +207,15 @@ 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 err := a.validateCommunityAlertMethod(req.Method); err != nil { methodTypes, err := a.validateAlertMethodReferences(req.Method)
if err != nil {
return err return err
} }
if req.Status != constant.AlertDisable {
if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
return err
}
}
upMap := make(map[string]interface{}) upMap := make(map[string]interface{})
upMap["id"] = req.ID upMap["id"] = req.ID
@@ -240,7 +273,16 @@ func (a AlertService) UpdateStatus(id uint, status string) error {
if alertInfo.ID == 0 { if alertInfo.ID == 0 {
return buserr.New("ErrRecordNotFound") return buserr.New("ErrRecordNotFound")
} }
err := alertRepo.Update(map[string]interface{}{"status": status}, repo.WithByID(alertInfo.ID)) methodTypes, err := a.validateAlertMethodReferences(alertInfo.Method)
if err != nil {
return err
}
if status == constant.AlertEnable {
if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
return err
}
}
err = alertRepo.Update(map[string]interface{}{"status": status}, repo.WithByID(alertInfo.ID))
if err != nil { if err != nil {
return err return err
} }
@@ -340,7 +382,7 @@ func executeDiskCommand() (string, error) {
cmdMgr2 := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second)) cmdMgr2 := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second))
stdout, err = cmdMgr2.RunWithStdout("df", "-lhT", "-P") stdout, err = cmdMgr2.RunWithStdout("df", "-lhT", "-P")
} }
if err != nil { if err != nil && strings.TrimSpace(stdout) == "" {
return stdout, err return stdout, err
} }
var lines []string var lines []string
@@ -384,6 +426,9 @@ func (a AlertService) PageAlertLogs(search dto.AlertLogSearch) (int64, []dto.Ale
if search.Count != 0 { if search.Count != 0 {
opts = append(opts, alertRepo.WithByCount(search.Count)) opts = append(opts, alertRepo.WithByCount(search.Count))
} }
if !search.StartTime.IsZero() && !search.EndTime.IsZero() {
opts = append(opts, repo.WithByCreatedAt(search.StartTime, search.EndTime))
}
opts = append(opts, repo.WithOrderDesc("created_at")) opts = append(opts, repo.WithOrderDesc("created_at"))
total, alerts, err := alertRepo.PageLog(search.Page, search.PageSize, opts...) total, alerts, err := alertRepo.PageLog(search.Page, search.PageSize, opts...)
@@ -409,6 +454,7 @@ func (a AlertService) parseAlertLog(item model.AlertLog) (dto.AlertLogDTO, error
if err := unmarshalAlertInfo(item.AlertDetail, &alertDetail); err != nil { if err := unmarshalAlertInfo(item.AlertDetail, &alertDetail); err != nil {
return dto.AlertLogDTO{}, err return dto.AlertLogDTO{}, err
} }
alertDetail.Task = nil
if err := unmarshalAlertInfo(item.AlertRule, &alertRule); err != nil { if err := unmarshalAlertInfo(item.AlertRule, &alertRule); err != nil {
return dto.AlertLogDTO{}, err return dto.AlertLogDTO{}, err
} }
@@ -491,7 +537,13 @@ func (a AlertService) GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertCon
} }
opts = append(opts, repo.WithByStatus(constant.AlertEnable)) opts = append(opts, repo.WithByStatus(constant.AlertEnable))
configs, err := alertRepo.AlertConfigList(opts...) configs, err := alertRepo.AlertConfigList(opts...)
return configs, err if err != nil {
return nil, err
}
if err := exposeCustomAlertConfigSecrets(configs); err != nil {
return nil, err
}
return configs, nil
} }
func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error) { func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error) {
@@ -502,13 +554,49 @@ func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []mode
if len(req.ExcludeTypes) > 0 { if len(req.ExcludeTypes) > 0 {
opts = append(opts, alertRepo.WithByTypeNotIn(req.ExcludeTypes)) opts = append(opts, alertRepo.WithByTypeNotIn(req.ExcludeTypes))
} }
return alertRepo.PageAlertConfig(req.Page, req.PageSize, opts...) total, configs, err := alertRepo.PageAlertConfig(req.Page, req.PageSize, opts...)
if err != nil {
return 0, nil, err
}
if err := exposeCustomAlertConfigSecrets(configs); err != nil {
return 0, nil, err
}
return total, configs, nil
} }
func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error { func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error {
if req.Type == constant.Custom {
if req.ID != 0 && req.Revision == nil {
return repo.ErrAlertConfigRevisionRequired
}
return a.updateCustomAlertConfig(req, operator)
}
usesMutation, err := alertconfig.UsesMutation(req.Type, req.Config)
if err != nil {
return err
}
if req.ID != 0 && usesMutation && req.Revision == nil {
return repo.ErrAlertConfigRevisionRequired
}
var existing *model.AlertConfig
if req.ID != 0 {
stored, err := alertRepo.GetConfigById(req.ID)
if err != nil {
return err
}
if stored.Type != req.Type {
return fmt.Errorf("alert config %d has type %s, not %s", req.ID, stored.Type, req.Type)
}
existing = &stored
}
if err := a.validateCommunityAlertConfigType(req.Type); err != nil { if err := a.validateCommunityAlertConfigType(req.Type); err != nil {
return err return err
} }
prepared, err := alertconfig.Prepare(req.Type, req.Config, req.Status, existing)
if err != nil {
return err
}
req.Config = prepared
if err := a.checkAlertConfigDisplayNameUnique(req); err != nil { if err := a.checkAlertConfigDisplayNameUnique(req); err != nil {
return err return err
} }
@@ -523,7 +611,7 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
upMap["status"] = req.Status upMap["status"] = req.Status
upMap["config"] = req.Config upMap["config"] = req.Config
upMap["update_user"] = operator upMap["update_user"] = operator
if err := alertRepo.UpdateAlertConfig(upMap, repo.WithByID(req.ID)); err != nil { if err := alertRepo.UpdateAlertConfigWithRevision(upMap, req.Revision, repo.WithByID(req.ID)); err != nil {
return err return err
} }
} else { } else {
@@ -541,6 +629,99 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
return nil return nil
} }
func (a AlertService) updateCustomAlertConfig(req dto.AlertConfigUpdate, operator string) error {
if err := validateAlertConfigStatus(req.Status); err != nil {
return err
}
var existing *model.AlertConfig
if req.ID != 0 {
config, err := alertRepo.GetConfigById(req.ID)
if err != nil {
return err
}
if config.Type != constant.Custom {
return fmt.Errorf("alert config %d is not a custom webhook", req.ID)
}
existing = &config
}
prepared, err := alertwebhook.Prepare(req.Config, req.Status, existing)
if err != nil {
return err
}
validatedReq := req
validatedReq.Config = prepared.Config
if err := a.checkAlertConfigDisplayNameUnique(validatedReq); err != nil {
return err
}
if existing != nil {
return alertRepo.UpdateAlertConfigWithRevision(map[string]interface{}{
"type": constant.Custom,
"title": req.Title,
"status": req.Status,
"config": prepared.Config,
"secret_config": prepared.SecretConfig,
"update_user": operator,
}, req.Revision, repo.WithByID(req.ID))
}
return alertRepo.CreateAlertConfig(&model.AlertConfig{
Type: constant.Custom,
Title: req.Title,
Status: req.Status,
Config: prepared.Config,
SecretConfig: prepared.SecretConfig,
CreateUser: operator,
UpdateUser: operator,
})
}
func (a AlertService) UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error {
if err := validateAlertConfigStatus(req.Status); err != nil {
return err
}
config, err := alertRepo.GetConfigById(req.ID)
if err != nil {
return err
}
if req.Status == constant.AlertEnable {
if err := a.validateCommunityAlertConfigType(config.Type); err != nil {
return err
}
if config.Type == constant.Custom {
if _, err := alertwebhook.Resolve(config); err != nil {
return err
}
}
}
return alertRepo.UpdateAlertConfig(map[string]interface{}{
"status": req.Status,
"update_user": operator,
}, repo.WithByID(req.ID))
}
func validateAlertConfigStatus(status string) error {
if status != constant.AlertEnable && status != constant.AlertDisable {
return fmt.Errorf("alert config status must be Enable or Disable")
}
return nil
}
func exposeCustomAlertConfigSecrets(configs []model.AlertConfig) error {
for index := range configs {
if configs[index].Type != constant.Custom {
continue
}
view, err := alertwebhook.PlainView(configs[index])
if err != nil {
return fmt.Errorf("build editable custom alert config %d: %w", configs[index].ID, err)
}
configs[index].Config = view
}
return nil
}
func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate) error { func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate) error {
if req.Type != constant.SMSConfig { if req.Type != constant.SMSConfig {
return nil return nil
@@ -565,6 +746,9 @@ func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate)
} }
func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdate) error { func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdate) error {
if req.Type != constant.Custom && (global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn") {
return nil
}
displayName := alertConfigDisplayName(req.Type, req.Config) displayName := alertConfigDisplayName(req.Type, req.Config)
if displayName == "" { if displayName == "" {
return nil return nil
@@ -588,37 +772,67 @@ func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdat
} }
func (a AlertService) validateCommunityAlertMethod(method string) error { func (a AlertService) validateCommunityAlertMethod(method string) error {
if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" { methodTypes, err := a.validateAlertMethodReferences(method)
return nil if err != nil {
} return err
if strings.TrimSpace(method) == "" {
return nil
} }
return a.validateAlertMethodEntitlement(methodTypes)
}
func (a AlertService) validateAlertMethodReferences(method string) ([]string, error) {
if strings.TrimSpace(method) == "" {
return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil)
}
methodTypes := make([]string, 0)
for _, item := range strings.Split(method, ",") { for _, item := range strings.Split(method, ",") {
item = strings.TrimSpace(item) item = strings.TrimSpace(item)
if item == "" { if item == "" {
continue continue
} }
configType := ""
if configID, err := strconv.ParseUint(item, 10, 64); err == nil { if configID, err := strconv.ParseUint(item, 10, 64); err == nil {
config, err := alertRepo.GetConfigById(uint(configID)) config, err := alertRepo.GetConfigById(uint(configID))
if err != nil { if err != nil {
return err return nil, err
} }
if _, ok := communityAlertMethodTypeNames[config.Type]; ok { configType = config.Type
return buserr.WithErr("ErrAlertMethodNotSupported", nil) } else {
var ok bool
configType, ok = legacyAlertMethodTypeMap[item]
if !ok {
return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil)
} }
}
if _, ok := supportedAlertMethodTypes[configType]; !ok {
return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil)
}
methodTypes = append(methodTypes, configType)
}
if len(methodTypes) == 0 {
return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil)
}
return methodTypes, nil
}
func (a AlertService) validateAlertMethodEntitlement(methodTypes []string) error {
for _, configType := range methodTypes {
if configType == constant.Custom {
continue continue
} }
if _, ok := communityAlertMethodTypeNames[item]; ok { if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" {
continue
}
if _, ok := communityAlertMethodTypeNames[configType]; ok {
return buserr.WithErr("ErrAlertMethodNotSupported", nil) return buserr.WithErr("ErrAlertMethodNotSupported", nil)
} }
} }
return nil return nil
} }
func (a AlertService) validateCommunityAlertConfigType(configType string) error { func (a AlertService) validateCommunityAlertConfigType(configType string) error {
if configType == constant.Custom {
return nil
}
if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" { if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" {
return nil return nil
} }
@@ -630,7 +844,7 @@ func (a AlertService) validateCommunityAlertConfigType(configType string) error
func alertConfigDisplayName(configType, configData string) string { func alertConfigDisplayName(configType, configData string) string {
switch configType { switch configType {
case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark, constant.SMS: case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark, constant.SMS, constant.Custom:
var cfg struct { var cfg struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
} }
@@ -669,20 +883,24 @@ func (a AlertService) DeleteAlertConfig(id uint) error {
} }
func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) { func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
username := req.UserName emailConfig, err := resolveEmailTestConfig(req)
if username == "" { if err != nil {
username = req.Sender return false, err
} }
encodedDisplayName := mime.BEncoding.Encode("UTF-8", req.DisplayName) username := emailConfig.UserName
if username == "" {
username = emailConfig.Sender
}
encodedDisplayName := mime.BEncoding.Encode("UTF-8", emailConfig.DisplayName)
cfg := email.SMTPConfig{ cfg := email.SMTPConfig{
Host: req.Host, Host: emailConfig.Host,
Port: req.Port, Port: emailConfig.Port,
Sender: req.Sender, Sender: emailConfig.Sender,
Username: username, Username: username,
Password: req.Password, Password: emailConfig.Password,
From: fmt.Sprintf(`"%s" <%s>`, encodedDisplayName, req.Sender), From: fmt.Sprintf(`"%s" <%s>`, encodedDisplayName, emailConfig.Sender),
Encryption: req.Encryption, Encryption: emailConfig.Encryption,
Recipient: req.Recipient, Recipient: emailConfig.Recipient,
} }
msg := email.EmailMessage{ msg := email.EmailMessage{
@@ -697,9 +915,94 @@ func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
return true, nil return true, nil
} }
func resolveEmailTestConfig(req dto.AlertConfigTest) (dto.AlertEmailConfig, error) {
emailConfig := dto.AlertEmailConfig{
Host: req.Host,
Port: req.Port,
Sender: req.Sender,
UserName: req.UserName,
Password: req.Password,
DisplayName: req.DisplayName,
Encryption: req.Encryption,
Recipient: req.Recipient,
}
if strings.TrimSpace(req.Config) != "" {
configType := req.Type
if configType == "" {
configType = constant.EmailConfig
}
if configType != constant.EmailConfig {
return dto.AlertEmailConfig{}, fmt.Errorf("alert config test type must be email")
}
var existing *model.AlertConfig
if req.ID != 0 {
stored, err := alertRepo.GetConfigById(req.ID)
if err != nil {
return dto.AlertEmailConfig{}, err
}
existing = &stored
}
prepared, err := alertconfig.Prepare(configType, req.Config, constant.AlertEnable, existing)
if err != nil {
return dto.AlertEmailConfig{}, err
}
if err := json.Unmarshal([]byte(prepared), &emailConfig); err != nil {
return dto.AlertEmailConfig{}, fmt.Errorf("decode email alert config: %w", err)
}
}
return emailConfig, nil
}
func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error) {
if req.Type != constant.Custom {
return dto.AlertConfigTestResult{}, fmt.Errorf("alert config test type must be custom")
}
var existing *model.AlertConfig
if req.ID != 0 {
config, err := alertRepo.GetConfigById(req.ID)
if err != nil {
return dto.AlertConfigTestResult{}, err
}
if config.Type != constant.Custom {
return dto.AlertConfigTestResult{}, fmt.Errorf("alert config %d is not a custom webhook", req.ID)
}
existing = &config
}
prepared, err := alertwebhook.Prepare(req.Config, constant.AlertEnable, existing)
if err != nil {
return dto.AlertConfigTestResult{}, err
}
resolved, err := alertwebhook.Resolve(model.AlertConfig{
Type: constant.Custom,
Config: prepared.Config,
SecretConfig: prepared.SecretConfig,
})
if err != nil {
return dto.AlertConfigTestResult{}, err
}
tester, ok := xpack.AlertProvider.(providers.CustomWebhookTester)
if !ok {
return dto.AlertConfigTestResult{
Success: false,
Message: providers.ErrCustomWebhookUnsupported.Error(),
}, nil
}
return tester.TestCustomWebhook(resolved)
}
func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator string) error { func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator string) error {
if err := a.validateCommunityAlertMethod(updateAlert.Method); err != nil { var methodTypes []string
return err if updateAlert.SendCount != 0 || strings.TrimSpace(updateAlert.Method) != "" {
var err error
methodTypes, err = a.validateAlertMethodReferences(updateAlert.Method)
if err != nil {
return err
}
}
if updateAlert.SendCount != 0 {
if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
return err
}
} }
upMap := make(map[string]interface{}) upMap := make(map[string]interface{})
var newStatus string var newStatus string
+101 -30
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"math" "math"
"net" "net"
@@ -32,6 +33,7 @@ const (
ResourceAlertInterval = 30 ResourceAlertInterval = 30
CheckIntervalSec = 3 CheckIntervalSec = 3
LoadCheckIntervalMin = 5 LoadCheckIntervalMin = 5
sshIPLoginWindow = 30 * time.Minute
) )
type AlertTaskHelper struct { type AlertTaskHelper struct {
@@ -512,10 +514,28 @@ func loadPanelLogin(alert dto.AlertDTO) {
} }
func loadSSHLogin(alert dto.AlertDTO) { func loadSSHLogin(alert dto.AlertDTO) {
count, isAlert, err := alertUtil.CountRecentFailedSSHLog(alert.Cycle, alert.Count) now := time.Now()
if err != nil { failedWindow := time.Duration(alert.Cycle) * time.Minute
global.LOG.Errorf("Failed to count recent failed ssh login logs: %v", err) loadWindow := failedWindow
if loadWindow < sshIPLoginWindow {
loadWindow = sshIPLoginWindow
} }
location, err := time.LoadLocation(common.LoadTimeZoneByCmd())
if err != nil {
global.LOG.Errorf("Failed to load timezone for ssh login logs: %v", err)
location = time.Local
}
histories, err := loadSSHAlertHistories(defaultSSHLogDir, now.Add(-loadWindow), now, location)
if err != nil {
global.LOG.Errorf("Failed to load ssh login logs: %v", err)
}
count, records := summarizeSSHLoginHistories(
histories,
now,
failedWindow,
strings.Split(strings.TrimSpace(alert.AdvancedParams), "\n"),
)
isAlert := count >= int(alert.Count)
if isAlert { if isAlert {
params := []dto.Param{ params := []dto.Param{
{ {
@@ -531,12 +551,6 @@ func loadSSHLogin(alert dto.AlertDTO) {
} }
sendAlerts(alert, "sshLogin", strconv.Itoa(count), "sshLogin", params) sendAlerts(alert, "sshLogin", strconv.Itoa(count), "sshLogin", params)
} }
whitelist := strings.Split(strings.TrimSpace(alert.AdvancedParams), "\n")
records, err := alertUtil.FindRecentSuccessLoginNotInWhitelist(30, whitelist)
if err != nil {
global.LOG.Errorf("Failed to check recent failed ip ssh login logs: %v", err)
}
records = filterSSHLoginEntriesNotInWhitelist(records, whitelist)
if len(records) > 0 { if len(records) > 0 {
quota := strings.Join(records, "\n") quota := strings.Join(records, "\n")
params := []dto.Param{ params := []dto.Param{
@@ -565,20 +579,6 @@ func filterLoginLogsNotInWhitelist(records []model.LoginLog, whitelist []string)
return filtered return filtered
} }
func filterSSHLoginEntriesNotInWhitelist(records []string, whitelist []string) []string {
filtered := make([]string, 0, len(records))
for _, record := range records {
ip := record
if idx := strings.Index(record, "-"); idx >= 0 {
ip = record[:idx]
}
if !isIPInWhitelist(ip, whitelist) {
filtered = append(filtered, record)
}
}
return filtered
}
func isIPInWhitelist(ip string, whitelist []string) bool { func isIPInWhitelist(ip string, whitelist []string) bool {
targetIP := net.ParseIP(strings.TrimSpace(ip)) targetIP := net.ParseIP(strings.TrimSpace(ip))
if targetIP == nil { if targetIP == nil {
@@ -695,9 +695,10 @@ func sendAlertsByConfigId(alert dto.AlertDTO, alertType, quota, quotaType string
func sendAlertsByLegacyMethod(alert dto.AlertDTO, alertType, quota, quotaType string, params []dto.Param, method string) { func sendAlertsByLegacyMethod(alert dto.AlertDTO, alertType, quota, quotaType string, params []dto.Param, method string) {
typeMap := map[string]string{ typeMap := map[string]string{
"mail": constant.Email, "mail": constant.Email,
constant.Bark: constant.Bark, constant.Bark: constant.Bark,
constant.SMS: constant.SMS, constant.SMS: constant.SMS,
constant.Custom: constant.Custom,
} }
configType, ok := typeMap[method] configType, ok := typeMap[method]
if !ok { if !ok {
@@ -705,7 +706,6 @@ func sendAlertsByLegacyMethod(alert dto.AlertDTO, alertType, quota, quotaType st
} }
config, err := alertRepo.GetConfig(alertRepo.WithByType(configType)) config, err := alertRepo.GetConfig(alertRepo.WithByType(configType))
if err != nil { if err != nil {
global.LOG.Errorf("alert config not found for type %s: %v", configType, err)
return return
} }
doSendAlert(alert, alertType, quota, quotaType, params, config) doSendAlert(alert, alertType, quota, quotaType, params, config)
@@ -786,7 +786,7 @@ func doSendAlert(alert dto.AlertDTO, alertType, quota, quotaType string, params
} }
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr) alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr)
case constant.WeCom, constant.DingTalk, constant.FeiShu: case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Custom:
todayCount, isValid := canSendAlertToday(alertType, quotaType, alert.SendCount, methodStr) todayCount, isValid := canSendAlertToday(alertType, quotaType, alert.SendCount, methodStr)
if !isValid { if !isValid {
return return
@@ -799,12 +799,31 @@ func doSendAlert(alert dto.AlertDTO, alertType, quota, quotaType string, params
} }
transport := xpack.MultiNodeProvider.LoadRequestTransport() transport := xpack.MultiNodeProvider.LoadRequestTransport()
agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo() agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo()
alertErr := xpack.AlertProvider.CreateWebhookAlertLog(alertType, alert, create, quotaType, params, config, transport, agentInfo) queued := false
var alertErr error
if config.Type == constant.Custom {
task := dto.AlertTaskMetadata{
AlertID: alert.ID,
Type: alertType,
Quota: quota,
QuotaType: quotaType,
Method: methodStr,
}
result, deliveryErr := xpack.DeliverCustomWebhookAlertLog(alertType, alert, create, quotaType, params, config, transport, agentInfo, task)
queued, alertErr = result.Queued, deliveryErr
if alertErr == nil && result.Queued {
_, alertErr = alertUtil.RecordQueuedAlertTask(result.LogID, task)
}
} else {
alertErr = xpack.AlertProvider.CreateWebhookAlertLog(alertType, alert, create, quotaType, params, config, transport, agentInfo)
}
if alertErr != nil { if alertErr != nil {
global.LOG.Infof("%s alert webhook %s push faild, err: %v", alertType, methodStr, alertErr) global.LOG.Infof("%s alert webhook %s push faild, err: %v", alertType, methodStr, alertErr)
return return
} }
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr) if !queued {
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr)
}
} }
} }
@@ -1098,3 +1117,55 @@ func calculateMinutesDifference(newDate time.Time) int {
minutesDifference := int(now.Sub(newDate).Minutes()) minutesDifference := int(now.Sub(newDate).Minutes())
return minutesDifference return minutesDifference
} }
func loadSSHAlertHistories(
baseDir string,
startTime, endTime time.Time,
location *time.Location,
) ([]dto.SSHHistory, error) {
fileList, err := listSSHLogFiles(baseDir)
if err != nil {
return nil, err
}
var (
histories []dto.SSHHistory
loadErr error
)
for _, file := range fileList {
items, err := loadSSHHistoriesFromFile(file.Name, "", "", startTime, endTime, file.Year, location)
if err != nil {
loadErr = errors.Join(loadErr, fmt.Errorf("load SSH log file %s: %w", file.Name, err))
continue
}
histories = append(histories, items...)
}
return histories, loadErr
}
func summarizeSSHLoginHistories(
histories []dto.SSHHistory,
now time.Time,
failedWindow time.Duration,
whitelist []string,
) (int, []string) {
failedStartTime := now.Add(-failedWindow)
successStartTime := now.Add(-sshIPLoginWindow)
failedCount := 0
var abnormalLogins []string
for _, item := range histories {
switch item.Status {
case constant.StatusFailed:
if isSSHLogWithinTimeRange(item.Date, failedStartTime, now) {
failedCount++
}
case constant.StatusSuccess:
if !isSSHLogWithinTimeRange(item.Date, successStartTime, now) || isIPInWhitelist(item.Address, whitelist) {
continue
}
abnormalLogins = append(abnormalLogins, fmt.Sprintf("%s-%s", item.Address, item.Date.Format(constant.DateTimeLayout)))
}
}
return failedCount, abnormalLogins
}
+45 -7
View File
@@ -75,7 +75,7 @@ func (s *AlertSender) sendByConfig(config model.AlertConfig, quota string, param
} else { } else {
s.sendBarkWithConfig(config, quota, params) s.sendBarkWithConfig(config, quota, params)
} }
case constant.WeCom, constant.DingTalk, constant.FeiShu: case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Custom:
if isResource { if isResource {
s.sendResourceWebhookWithConfig(config, quota, params) s.sendResourceWebhookWithConfig(config, quota, params)
} else { } else {
@@ -86,14 +86,13 @@ func (s *AlertSender) sendByConfig(config model.AlertConfig, quota string, param
func (s *AlertSender) sendByLegacyMethod(method string, quota string, params []dto.Param, isResource bool) { func (s *AlertSender) sendByLegacyMethod(method string, quota string, params []dto.Param, isResource bool) {
alertRepo := repo.NewIAlertRepo() alertRepo := repo.NewIAlertRepo()
typeMap := map[string]string{"mail": constant.Email, constant.Bark: constant.Bark, constant.SMS: constant.SMS} typeMap := map[string]string{"mail": constant.Email, constant.Bark: constant.Bark, constant.SMS: constant.SMS, constant.Custom: constant.Custom}
configType := method configType := method
if mapped, ok := typeMap[method]; ok { if mapped, ok := typeMap[method]; ok {
configType = mapped configType = mapped
} }
config, err := alertRepo.GetConfig(alertRepo.WithByType(configType)) config, err := alertRepo.GetConfig(alertRepo.WithByType(configType))
if err != nil { if err != nil {
global.LOG.Errorf("alert config not found for type %s: %v", configType, err)
return return
} }
if !alertUtil.IsAlertConfigEnabled(config) { if !alertUtil.IsAlertConfigEnabled(config) {
@@ -309,12 +308,31 @@ func (s *AlertSender) sendWebhookWithConfig(config model.AlertConfig, quota stri
} }
transport := xpack.MultiNodeProvider.LoadRequestTransport() transport := xpack.MultiNodeProvider.LoadRequestTransport()
agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo() agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo()
err := xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo) queued := false
var err error
if config.Type == constant.Custom {
task := dto.AlertTaskMetadata{
AlertID: s.alert.ID,
Type: s.alert.Type,
Quota: quota,
QuotaType: s.quotaType,
Method: strconv.Itoa(int(config.ID)),
}
result, deliveryErr := xpack.DeliverCustomWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo, task)
queued, err = result.Queued, deliveryErr
if err == nil && result.Queued {
_, err = alertUtil.RecordQueuedAlertTask(result.LogID, task)
}
} else {
err = xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo)
}
if err != nil { if err != nil {
global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, config.Type, err) global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, config.Type, err)
return return
} }
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID))) if !queued {
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID)))
}
} }
func (s *AlertSender) sendResourceWebhookWithConfig(config model.AlertConfig, quota string, params []dto.Param) { func (s *AlertSender) sendResourceWebhookWithConfig(config model.AlertConfig, quota string, params []dto.Param) {
@@ -335,11 +353,31 @@ func (s *AlertSender) sendResourceWebhookWithConfig(config model.AlertConfig, qu
} }
transport := xpack.MultiNodeProvider.LoadRequestTransport() transport := xpack.MultiNodeProvider.LoadRequestTransport()
agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo() agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo()
if err := xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo); err != nil { queued := false
var err error
if config.Type == constant.Custom {
task := dto.AlertTaskMetadata{
AlertID: s.alert.ID,
Type: s.alert.Type,
Quota: quota,
QuotaType: s.quotaType,
Method: strconv.Itoa(int(config.ID)),
}
result, deliveryErr := xpack.DeliverCustomWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo, task)
queued, err = result.Queued, deliveryErr
if err == nil && result.Queued {
_, err = alertUtil.RecordQueuedAlertTask(result.LogID, task)
}
} else {
err = xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo)
}
if err != nil {
global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, config.Type, err) global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, config.Type, err)
return return
} }
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID))) if !queued {
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID)))
}
} }
func (s *AlertSender) sendWebhook(quota string, params []dto.Param, method string) { func (s *AlertSender) sendWebhook(quota string, params []dto.Param, method string) {
+29 -14
View File
@@ -223,6 +223,9 @@ func (a AppService) GetAppDetailByKey(appKey, version string) (response.AppDetai
if err != nil { if err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
if err = checkVllmVersionAccess(app.Key, version); err != nil {
return appDetailDTO, err
}
appDetail, err := appDetailRepo.GetFirst(appDetailRepo.WithAppId(app.ID), appDetailRepo.WithVersion(version)) appDetail, err := appDetailRepo.GetFirst(appDetailRepo.WithAppId(app.ID), appDetailRepo.WithVersion(version))
if err != nil { if err != nil {
return appDetailDTO, err return appDetailDTO, err
@@ -241,14 +244,17 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err != nil { if err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
app, err := appRepo.GetFirst(repo.WithByID(detail.AppId))
if err != nil {
return appDetailDTO, err
}
if err = checkVllmVersionAccess(app.Key, detail.Version); err != nil {
return appDetailDTO, err
}
appDetailDTO.AppDetail = detail appDetailDTO.AppDetail = detail
appDetailDTO.Enable = true appDetailDTO.Enable = true
if appType == "runtime" { if appType == "runtime" {
app, err := appRepo.GetFirst(repo.WithByID(appID))
if err != nil {
return appDetailDTO, err
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version) versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
@@ -303,6 +309,9 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
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")
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 {
return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
}
if err != nil { if err != nil {
return appDetailDTO, buserr.WithDetail("ErrGetCompose", err.Error(), err) return appDetailDTO, buserr.WithDetail("ErrGetCompose", err.Error(), err)
} }
@@ -316,10 +325,6 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose) appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose)
app, err := appRepo.GetFirst(repo.WithByID(detail.AppId))
if err != nil {
return appDetailDTO, err
}
if err := checkLimit(app); err != nil { if err := checkLimit(app); err != nil {
appDetailDTO.Enable = false appDetailDTO.Enable = false
} }
@@ -371,6 +376,9 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
if err != nil { if err != nil {
return return
} }
if err = checkVllmVersionAccess(app.Key, appDetail.Version); err != nil {
return
}
if DatabaseKeys[app.Key] > 0 { if DatabaseKeys[app.Key] > 0 {
if existDatabases, _ := databaseRepo.GetList(repo.WithByName(req.Name)); len(existDatabases) > 0 { if existDatabases, _ := databaseRepo.GetList(repo.WithByName(req.Name)); len(existDatabases) > 0 {
err = buserr.New("ErrRemoteExist") err = buserr.New("ErrRemoteExist")
@@ -426,7 +434,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)
_, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) var statusCode int
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
err = buserr.New("ErrAppVersionUnavailable")
return
}
if err != nil { if err != nil {
return return
} }
@@ -475,15 +488,17 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
index++ index++
} }
newServiceName := strings.ToLower(appInstall.Name) newServiceName := strings.ToLower(appInstall.Name)
if app.Limit == 0 && newServiceName != serviceName && len(servicesMap) == 1 { if app.Limit == 0 && newServiceName != serviceName && len(servicesMap) == 1 && !req.KeepServiceName {
servicesMap[newServiceName] = servicesMap[serviceName] servicesMap[newServiceName] = servicesMap[serviceName]
delete(servicesMap, serviceName) delete(servicesMap, serviceName)
serviceName = newServiceName serviceName = newServiceName
} }
appInstall.ServiceName = serviceName appInstall.ServiceName = serviceName
if err = addDockerComposeCommonParam(composeMap, appInstall.ServiceName, req.AppContainerConfig, req.Params); err != nil { if !req.SkipComposeCommonConfig {
return if err = addDockerComposeCommonParam(composeMap, appInstall.ServiceName, req.AppContainerConfig, req.Params); err != nil {
return
}
} }
var ( var (
composeByte []byte composeByte []byte
@@ -551,7 +566,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err return err
} }
} }
if executeScript { if executeScript || req.UseLifecycleScripts {
if err = runScript(t, appInstall, "init"); err != nil { if err = runScript(t, appInstall, "init"); err != nil {
return err return err
} }
@@ -564,7 +579,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err return err
} }
} }
if err = upApp(t, appInstall, req.PullImage); err != nil { if err = upApp(t, appInstall, req.PullImage, req.UseLifecycleScripts); err != nil {
return err return err
} }
updateToolApp(appInstall) updateToolApp(appInstall)
+114 -13
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps"
"math" "math"
"net/http" "net/http"
"os" "os"
@@ -13,12 +14,14 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"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/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"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/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
@@ -252,6 +255,9 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return buserr.New("ErrInstallDirNotFound") return buserr.New("ErrInstallDirNotFound")
} }
dockerComposePath := install.GetComposePath() dockerComposePath := install.GetComposePath()
if req.UseLifecycleScripts && (req.Operate == constant.Start || req.Operate == constant.Stop || req.Operate == constant.Restart) {
return operateAppWithLifecycleScripts(install, req, nil)
}
switch req.Operate { switch req.Operate {
case constant.Rebuild: case constant.Rebuild:
return rebuildApp(install) return rebuildApp(install)
@@ -275,12 +281,13 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return syncAppInstallStatus(&install, false) return syncAppInstallStatus(&install, false)
case constant.Delete: case constant.Delete:
deleteReq := request.AppInstallDelete{ deleteReq := request.AppInstallDelete{
Install: install, Install: install,
DeleteBackup: req.DeleteBackup, DeleteBackup: req.DeleteBackup,
ForceDelete: req.ForceDelete, ForceDelete: req.ForceDelete,
DeleteDB: req.DeleteDB, DeleteDB: req.DeleteDB,
DeleteImage: req.DeleteImage, DeleteImage: req.DeleteImage,
TaskID: req.TaskID, TaskID: req.TaskID,
UseLifecycleScripts: req.UseLifecycleScripts,
} }
if err = deleteAppInstall(deleteReq); err != nil && !req.ForceDelete { if err = deleteAppInstall(deleteReq); err != nil && !req.ForceDelete {
return err return err
@@ -294,6 +301,7 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
DetailID: req.DetailId, DetailID: req.DetailId,
Backup: req.Backup, Backup: req.Backup,
PullImage: req.PullImage, PullImage: req.PullImage,
DeleteImage: req.DeleteImage,
DockerCompose: req.DockerCompose, DockerCompose: req.DockerCompose,
TaskID: req.TaskID, TaskID: req.TaskID,
} }
@@ -311,6 +319,70 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
} }
} }
func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppInstalledOperate, onFailure func(error)) error {
taskType := task.TaskUpdate
switch req.Operate {
case constant.Start:
install.Status = constant.StatusStarting
case constant.Restart:
taskType = task.TaskRestart
install.Status = constant.StatusRestarting
case constant.Stop:
install.Status = constant.StatusWaiting
default:
return errors.New("lifecycle script operation not supported")
}
install.Message = ""
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
return err
}
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil {
return err
}
operation := string(req.Operate)
operationTask.AddSubTaskWithOps(
task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
func(t *task.Task) error {
if err := runScript(t, &install, operation); err != nil {
return err
}
if req.Operate == constant.Stop {
install.Status = constant.StatusStopped
install.Message = ""
return appInstallRepo.Save(context.Background(), &install)
}
containerNames, err := getContainerNames(install)
if err != nil {
return err
}
if len(containerNames) == 0 {
return buserr.WithName("ErrContainerNotFound", install.Name)
}
install.ContainerName = strings.Join(containerNames, ",")
install.Status = constant.StatusRunning
install.Message = ""
return appInstallRepo.Save(context.Background(), &install)
},
nil,
0,
time.Hour,
)
go func() {
if taskErr := operationTask.Execute(); taskErr != nil {
if onFailure != nil {
onFailure(taskErr)
return
}
install.Status = constant.StatusUpErr
install.Message = taskErr.Error()
_ = appInstallRepo.Save(context.Background(), &install)
}
}()
return nil
}
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error { func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
installed, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID)) installed, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if err != nil { if err != nil {
@@ -373,8 +445,10 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
return err return err
} }
} }
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil { if !req.SkipComposeCommonConfig {
return err if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
return err
}
} }
composeByte, err := yaml.Marshal(composeMap) composeByte, err := yaml.Marshal(composeMap)
if err != nil { if err != nil {
@@ -407,7 +481,7 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
if err != nil { if err != nil {
return err return err
} }
backupEnvMaps := oldEnvMaps backupEnvMaps := maps.Clone(oldEnvMaps)
handleMap(req.Params, oldEnvMaps) handleMap(req.Params, oldEnvMaps)
paramByte, err := json.Marshal(oldEnvMaps) paramByte, err := json.Marshal(oldEnvMaps)
if err != nil { if err != nil {
@@ -419,13 +493,32 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
} }
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
_ = fileOp.WriteFile(installed.GetComposePath(), strings.NewReader(installed.DockerCompose), constant.DirPerm) _ = fileOp.WriteFile(installed.GetComposePath(), strings.NewReader(installed.DockerCompose), constant.DirPerm)
if err := rebuildApp(installed); err != nil { restoreConfig := func(operationErr error) {
_ = env.Write(backupEnvMaps, envPath) _ = env.Write(backupEnvMaps, envPath)
_ = fileOp.WriteFile(installed.GetComposePath(), strings.NewReader(backupDockerCompose), constant.DirPerm) _ = fileOp.WriteFile(installed.GetComposePath(), strings.NewReader(backupDockerCompose), constant.DirPerm)
failed := oldInstalled
failed.Status = constant.StatusUpErr
failed.Message = operationErr.Error()
_ = appInstallRepo.Save(context.Background(), &failed)
}
if req.UseLifecycleScripts {
err = operateAppWithLifecycleScripts(installed, request.AppInstalledOperate{
InstallId: installed.ID,
Operate: constant.Restart,
TaskID: req.TaskID,
UseLifecycleScripts: true,
}, restoreConfig)
} else {
err = rebuildApp(installed)
}
if err != nil {
restoreConfig(err)
return err return err
} }
installed.Status = constant.StatusRunning if !req.UseLifecycleScripts {
_ = appInstallRepo.Save(context.Background(), &installed) installed.Status = constant.StatusRunning
_ = appInstallRepo.Save(context.Background(), &installed)
}
proxyChanged := hasAppInstallProxyPassChanged(&oldInstalled, &installed) proxyChanged := hasAppInstallProxyPassChanged(&oldInstalled, &installed)
currentProxy, currentProxyErr := getAppInstallProxyPass(&installed) currentProxy, currentProxyErr := getAppInstallProxyPass(&installed)
@@ -582,6 +675,9 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
return versions, err return versions, err
} }
for _, detail := range details { for _, detail := range details {
if !canAccessVllmVersion(app.Key, 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
@@ -600,6 +696,9 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
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")
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 {
return versions, buserr.New("ErrAppVersionUnavailable")
}
if err != nil { if err != nil {
return versions, err return versions, err
} }
@@ -832,7 +931,9 @@ 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 {
if appInstall.Status == constant.StatusInstalling || appInstall.Status == constant.StatusRebuilding || appInstall.Status == constant.StatusUpgrading || appInstall.Status == constant.StatusUninstalling { switch appInstall.Status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
return nil return nil
} }
cli, err := docker.NewClient() cli, err := docker.NewClient()
+951
View File
@@ -0,0 +1,951 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path"
"sort"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"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/buserr"
"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/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/joho/godotenv"
)
type appUpgradePhase int
const (
appUpgradePreparing appUpgradePhase = iota
appUpgradePrepared
appUpgradeStopped
appUpgradeBackedUp
appUpgradeDown
appUpgradeMutated
appUpgradeStarted
appUpgradeReady
appUpgradeCommitted
)
const composeServiceLabel = "com.docker.compose.service"
var appUpgradeLocks sync.Map
type appUpgradeSnapshot interface {
Restore() error
Cleanup()
}
type upgradeFileSnapshot struct {
installPath string
backupPath string
paths []string
existing map[string]bool
}
type appUpgradeContext struct {
req request.AppInstallUpgrade
original model.AppInstall
candidate model.AppInstall
detail model.AppDetail
phase appUpgradePhase
stopAttempted bool
downAttempted bool
rollbackErr error
detailDir string
stageDir string
envContent []byte
oldEnvContent []byte
oldDockerCompose string
oldImageIDs []appImageID
backupFile string
snapshot appUpgradeSnapshot
createdPaths []string
}
func upgradeInstall(req request.AppInstallUpgrade) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if err != nil {
return err
}
if install.Status == constant.StatusUpgrading {
return buserr.New("TaskIsExecuting")
}
if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, install.ID); err != nil {
return err
}
if _, loaded := appUpgradeLocks.LoadOrStore(install.ID, struct{}{}); loaded {
return buserr.New("TaskIsExecuting")
}
releaseLock := true
defer func() {
if releaseLock {
appUpgradeLocks.Delete(install.ID)
}
}()
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil {
return err
}
if err = checkVllmVersionAccess(install.App.Key, detail.Version); err != nil {
return err
}
if install.App.Key == vllmAppKeyForUpgrade && !isVllmUpgradeVersionAllowed(install.Version, detail.Version, loadVllmImageFromEnv(install.Env)) {
return errors.New("vLLM can only upgrade within the same image type")
}
if install.Version == detail.Version {
return errors.New("two version is same")
}
upgradeTask, err := task.NewTaskWithOps(install.Name, task.TaskUpgrade, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil {
return err
}
ctx := &appUpgradeContext{
req: req,
original: install,
candidate: install,
detail: detail,
phase: appUpgradePreparing,
oldDockerCompose: install.DockerCompose,
}
upgradeTask.AddSubTaskWithOps(i18n.GetMsgByKey("UpgradePrepare"), ctx.prepare, nil, 0, 0)
upgradeTask.AddSubTaskWithOps(
task.GetTaskName(install.Name, task.TaskUpgrade, task.TaskScopeApp),
ctx.cutover,
func(t *task.Task) {
ctx.rollbackErr = ctx.rollback(t)
},
0,
0,
)
upgradingInstall := install
upgradingInstall.Status = constant.StatusUpgrading
upgradingInstall.Message = ""
if err = appInstallRepo.Save(context.Background(), &upgradingInstall); err != nil {
return err
}
releaseLock = false
go func() {
defer appUpgradeLocks.Delete(install.ID)
defer ctx.cleanup()
taskErr := upgradeTask.Execute()
if taskErr == nil {
return
}
if ctx.rollbackErr != nil {
taskErr = fmt.Errorf("%w; %s: %v", taskErr, i18n.GetMsgByKey("UpgradeRollbackFailed"), ctx.rollbackErr)
upgradeTask.Task.ErrorMsg = taskErr.Error()
_ = repo.NewITaskRepo().Update(context.Background(), upgradeTask.Task)
}
if !ctx.stopAttempted || ctx.rollbackErr == nil {
restored := ctx.original
_ = appInstallRepo.Save(context.Background(), &restored)
return
}
failed := ctx.original
failed.Status = constant.StatusUpgradeErr
failed.Message = taskErr.Error()
_ = appInstallRepo.Save(context.Background(), &failed)
}()
return nil
}
func (u *appUpgradeContext) prepare(t *task.Task) error {
fileOp := files.NewFileOp()
u.detailDir = path.Join(u.original.App.GetAppResourcePath(), u.detail.Version)
if u.original.App.Resource == constant.AppResourceRemote {
if err := downloadApp(u.original.App, u.detail, nil, t.Logger); err != nil {
return err
}
}
if !fileOp.Stat(u.detailDir) {
return buserr.WithName("ErrFileNotFound", u.detailDir)
}
if u.detail.DockerCompose == "" {
composeContent, err := fileOp.GetContent(path.Join(u.detailDir, "docker-compose.yml"))
if err != nil {
return err
}
u.detail.DockerCompose = string(composeContent)
_ = appDetailRepo.Update(context.Background(), u.detail)
}
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
}
var err error
u.oldEnvContent, err = fileOp.GetContent(u.original.GetEnvPath())
if err != nil {
return err
}
u.stageDir, err = os.MkdirTemp(u.original.GetAppPath(), "."+u.original.Name+"-upgrade-")
if err != nil {
return err
}
if err = fileOp.CopyDirWithNewName(u.detailDir, u.stageDir, "."); err != nil {
return err
}
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, ".env"); err != nil {
return err
}
if u.original.App.Key == constant.AppOpenclaw {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
return err
}
}
if u.original.App.Key == constant.AppOpenresty {
for _, relativePath := range []string{
nginxModuleBuildDir,
nginxModuleModulesDir,
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
} {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, relativePath); err != nil {
return err
}
}
}
stagedInstall := u.original
stagedInstall.Name = path.Base(u.stageDir)
stagedInstall.Version = u.detail.Version
stagedInstall.AppDetailId = u.req.DetailID
if stagedInstall.App.Key == vllmAppKeyForUpgrade {
envs := make(map[string]interface{})
if err = json.Unmarshal([]byte(stagedInstall.Env), &envs); err != nil {
return err
}
image := buildVllmUpgradeImage(loadVllmImageFromEnv(stagedInstall.Env), u.original.Version, u.detail.Version)
envs[vllmImageEnvKey] = image
paramBytes, marshalErr := json.Marshal(envs)
if marshalErr != nil {
return marshalErr
}
stagedInstall.Env = string(paramBytes)
}
if err = migrateOpenclawProtocolUpgrade(&stagedInstall, u.original.Version, u.detail.Version); err != nil {
return err
}
u.candidate = stagedInstall
u.candidate.Name = u.original.Name
u.candidate.DockerCompose, err = renderUpgradeCompose(u.candidate, u.detail, u.req.DockerCompose)
if err != nil {
return err
}
if strings.TrimSpace(u.candidate.DockerCompose) == "" {
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
}
u.envContent, err = renderUpgradeEnv(&u.candidate, u.oldEnvContent)
if err != nil {
return err
}
if err = writeUpgradeFile(path.Join(u.stageDir, ".env"), u.envContent, constant.FilePerm); err != nil {
return err
}
if err = writeUpgradeFile(path.Join(u.stageDir, "docker-compose.yml"), []byte(u.candidate.DockerCompose), constant.FilePerm); err != nil {
return err
}
project, err := docker.GetComposeProject(u.original.Name, u.stageDir, []byte(u.candidate.DockerCompose), u.envContent, false)
if err != nil {
return err
}
hasBuild := false
for _, service := range project.Services {
if service.Image == "" && service.Build == nil {
return fmt.Errorf("compose service %s has neither image nor build configuration", service.Name)
}
hasBuild = hasBuild || service.Build != nil
}
if u.req.DeleteImage {
dockerClient, clientErr := docker.NewClient()
if clientErr != nil {
return clientErr
}
u.oldImageIDs, err = getAppImageIDsByCompose(dockerClient, u.oldEnvContent, []byte(u.oldDockerCompose))
dockerClient.Close()
if err != nil {
return err
}
}
images := make([]string, 0, len(project.Services))
for _, service := range project.Services {
if service.Image != "" {
images = append(images, service.Image)
}
}
if err = prepareUpgradeImages(t, images, u.req.PullImage); err != nil {
return err
}
if u.candidate.App.Key == constant.AppOpenresty {
if err = u.prepareOpenresty(t, stagedInstall); err != nil {
return err
}
if err = verifyUpgradeImages(images); err != nil {
return err
}
} else if hasBuild {
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image"))
t.LogStart(logStr)
if err = compose.BuildWithTask(path.Join(u.stageDir, "docker-compose.yml"), project.Name, t); err != nil {
t.LogFailedWithErr(logStr, err)
return err
}
t.LogSuccess(logStr)
if err = verifyUpgradeImages(images); err != nil {
return err
}
}
if u.original.App.Resource == constant.AppResourceRemote {
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
}
u.phase = appUpgradePrepared
return nil
}
func (u *appUpgradeContext) prepareOpenresty(t *task.Task, stagedInstall model.AppInstall) error {
fileOp := files.NewFileOp()
detailBuildDir := path.Join(u.detailDir, nginxModuleBuildDir)
installBuildDir := path.Join(u.stageDir, nginxModuleBuildDir)
if !fileOp.Stat(installBuildDir) {
if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil {
return err
}
}
if err := copyAppDetailMissing(fileOp, detailBuildDir, installBuildDir); err != nil {
return err
}
if err := fileOp.DeleteDir(path.Join(installBuildDir, nginxModuleTmpDir)); err != nil {
return err
}
if err := fileOp.CopyDir(path.Join(detailBuildDir, nginxModuleTmpDir), installBuildDir); err != nil {
return err
}
for _, fileName := range []string{"Dockerfile", "nginx.conf", "nginx.vh.default.conf"} {
if err := fileOp.CopyFile(path.Join(detailBuildDir, fileName), installBuildDir); err != nil {
return err
}
}
if err := syncNginxModuleBuilder(detailBuildDir, installBuildDir); err != nil {
return err
}
targetCatalogSource := path.Join(detailBuildDir, nginxModuleCatalogFile)
if !fileOp.Stat(targetCatalogSource) {
return fmt.Errorf("target OpenResty module catalog not found: %s", targetCatalogSource)
}
targetCatalogPath := path.Join(installBuildDir, nginxModuleCatalogPendingFile)
if err := stageNginxModuleCatalog(targetCatalogSource, targetCatalogPath); err != nil {
return err
}
stagedInstall.Name = path.Base(u.stageDir)
stagedInstall.Version = u.candidate.Version
stagedInstall.Env = u.candidate.Env
stagedInstall.DockerCompose = u.candidate.DockerCompose
return buildNginx(t, stagedInstall, targetCatalogPath)
}
func (u *appUpgradeContext) cutover(t *task.Task) error {
u.stopAttempted = true
t.LogStart(i18n.GetMsgByKey("UpgradeStop"))
if out, err := compose.Stop(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeStop"), err)
return err
}
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
u.phase = appUpgradeStopped
var err error
if u.original.App.Key == constant.AppOpenresty {
u.snapshot, err = createOpenrestyUpgradeSnapshot(u.original.GetPath())
} else {
snapshotPaths := []string{".env", "docker-compose.yml", "scripts"}
if u.original.App.Key == constant.AppOpenclaw {
snapshotPaths = append(snapshotPaths, path.Join("data", "conf", "openclaw.json"))
}
u.snapshot, err = createUpgradeFileSnapshot(u.original.GetPath(), snapshotPaths)
}
if err != nil {
return err
}
if u.req.Backup {
if err = u.backup(t); err != nil {
return err
}
u.phase = appUpgradeBackedUp
} else {
t.Log(i18n.GetMsgByKey("UpgradeBackupDisabled"))
}
u.downAttempted = true
if out, downErr := compose.Down(u.original.GetComposePath()); downErr != nil {
if out != "" {
downErr = fmt.Errorf("%s: %w", out, downErr)
}
return downErr
}
u.phase = appUpgradeDown
u.phase = appUpgradeMutated
if err = u.applyStagedFiles(); err != nil {
return err
}
if err = writeUpgradeFile(u.original.GetEnvPath(), u.envContent, constant.FilePerm); err != nil {
return err
}
if err = runScript(t, &u.candidate, "upgrade"); err != nil {
return err
}
if err = writeUpgradeFile(u.original.GetComposePath(), []byte(u.candidate.DockerCompose), constant.FilePerm); err != nil {
return err
}
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
t.LogStart(logStr)
if out, upErr := compose.UpWithoutBuild(u.original.GetComposePath()); upErr != nil {
if out != "" {
upErr = fmt.Errorf("%s: %w", out, upErr)
}
t.LogFailedWithErr(logStr, upErr)
return upErr
}
t.LogSuccess(logStr)
u.phase = appUpgradeStarted
t.LogStart(i18n.GetMsgByKey("UpgradeWaitReady"))
containerNames, err := waitAppContainersReady(context.Background(), u.candidate)
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeWaitReady"), err)
return err
}
t.LogSuccess(i18n.GetMsgByKey("UpgradeWaitReady"))
u.phase = appUpgradeReady
u.candidate.ContainerName = strings.Join(containerNames, ",")
u.candidate.Status = constant.StatusRunning
u.candidate.Message = ""
if u.candidate.App.Key == constant.AppOpenresty {
liveCatalogPath := path.Join(u.candidate.GetPath(), nginxModuleBuildDir, nginxModuleCatalogPendingFile)
if err = commitStaticNginxModuleBuilds(u.candidate, liveCatalogPath, t); err != nil {
return err
}
activeCatalogPath := path.Join(u.candidate.GetPath(), nginxModuleBuildDir, nginxModuleCatalogFile)
if err = activateNginxModuleCatalogAndCommit(liveCatalogPath, activeCatalogPath, func() error {
return appInstallRepo.Save(context.Background(), &u.candidate)
}); err != nil {
return err
}
// Upgrades deliberately keep the user's nginx.conf, so corrected gzip
// defaults shipped with a new version would never reach existing
// installations. Rewrite only an untouched factory configuration, and
// never fail the upgrade over it.
if gzipErr := upgradeStockNginxGzipConfig(u.candidate); gzipErr != nil {
t.Logf("WARNING: update stock gzip configuration failed, keeping the current one: %v", gzipErr)
}
} else if err = appInstallRepo.Save(context.Background(), &u.candidate); err != nil {
return err
}
u.phase = appUpgradeCommitted
u.deleteOldImages(t)
return nil
}
func (u *appUpgradeContext) backup(t *task.Task) error {
fileName := fmt.Sprintf("upgrade_backup_%s_%s.tar.gz", u.original.Name, time.Now().Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5))
record, err := backupAppWithParentTask(&u.original, t, fileName)
if err != nil {
return buserr.WithNameAndErr("ErrAppBackup", u.original.Name, err)
}
u.backupFile = path.Join(global.Dir.LocalBackupDir, record.FileDir, record.FileName)
info, err := os.Stat(u.backupFile)
if err != nil || info.Size() == 0 || record.Status != constant.StatusSuccess {
if err == nil {
err = errors.New("backup archive is empty or incomplete")
}
markBackupFailed(record.ID, err)
return buserr.WithNameAndErr("ErrAppBackup", u.original.Name, err)
}
backupRecordService := NewIBackupRecordService()
backups, _ := backupRecordService.ListAppRecords(u.original.App.Key, u.original.Name, "upgrade_backup")
if len(backups) > 3 {
deleteIDs := make([]uint, 0, len(backups)-3)
for _, backup := range backups[:len(backups)-3] {
deleteIDs = append(deleteIDs, backup.ID)
}
_ = backupRecordService.BatchDeleteRecord(deleteIDs)
}
return nil
}
func (u *appUpgradeContext) applyStagedFiles() error {
fileOp := files.NewFileOp()
if err := copyAppDetailMissingTracked(fileOp, u.detailDir, u.original.GetPath(), &u.createdPaths); err != nil {
return err
}
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), "scripts"); err != nil {
return err
}
if u.original.App.Key == constant.AppOpenclaw {
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), path.Join("data", "conf", "openclaw.json")); err != nil {
return err
}
}
if u.original.App.Key == constant.AppOpenresty {
for _, relativePath := range []string{
nginxModuleBuildDir,
nginxModuleModulesDir,
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
} {
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), relativePath); err != nil {
return err
}
}
}
return nil
}
func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if !u.stopAttempted {
return nil
}
logStr := i18n.GetWithName("AppRecover", u.original.Name)
t.LogStart(logStr)
defer func() {
if rollbackErr != nil {
t.LogFailedWithErr(logStr, rollbackErr)
} else {
t.LogSuccess(logStr)
}
}()
if !u.downAttempted {
if out, err := compose.Operate(u.original.GetComposePath(), "start"); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
return err
}
return u.finishRollback()
}
if u.phase < appUpgradeMutated {
if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
return err
}
return u.finishRollback()
}
if out, err := compose.Down(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
rollbackErr = err
}
if u.backupFile != "" {
_ = u.restoreManagedFiles()
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
_, _ = compose.UpWithoutBuild(u.original.GetComposePath())
return errors.Join(rollbackErr, err)
}
} else {
if err := u.restoreManagedFiles(); err != nil {
return errors.Join(rollbackErr, err)
}
if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
return errors.Join(rollbackErr, err)
}
}
return errors.Join(rollbackErr, u.finishRollback())
}
func (u *appUpgradeContext) finishRollback() error {
if _, err := waitAppContainersReady(context.Background(), u.original); err != nil {
return err
}
restored := u.original
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
return err
}
return nil
}
func (u *appUpgradeContext) restoreManagedFiles() error {
var restoreErr error
if u.snapshot != nil {
restoreErr = u.snapshot.Restore()
}
for index := len(u.createdPaths) - 1; index >= 0; index-- {
if err := os.RemoveAll(u.createdPaths[index]); err != nil {
restoreErr = errors.Join(restoreErr, err)
}
}
return restoreErr
}
func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
if !u.req.DeleteImage {
return
}
excludeImages, err := docker.GetImagesFromDockerCompose(u.envContent, []byte(u.candidate.DockerCompose))
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
return
}
dockerClient, err := docker.NewClient()
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
return
}
defer dockerClient.Close()
if err = deleteAppImagesByIDs(t, dockerClient, u.oldImageIDs, excludeImages); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
}
}
func (u *appUpgradeContext) cleanup() {
if u.snapshot != nil {
u.snapshot.Cleanup()
}
if u.stageDir != "" {
_ = os.RemoveAll(u.stageDir)
}
}
type upgradeImageClient interface {
PullImageWithProcess(*task.Task, string) error
ImageExists(string) (bool, error)
Close()
}
func prepareUpgradeImages(t *task.Task, images []string, pull bool) error {
dockerClient, err := docker.NewClient()
if err != nil {
return err
}
return prepareUpgradeImagesWithClient(t, dockerClient, images, pull)
}
func prepareUpgradeImagesWithClient(t *task.Task, dockerClient upgradeImageClient, images []string, pull bool) error {
defer dockerClient.Close()
seen := make(map[string]struct{}, len(images))
for _, image := range images {
image = strings.TrimSpace(image)
if image == "" {
continue
}
if _, ok := seen[image]; ok {
continue
}
seen[image] = struct{}{}
if pull {
if t != nil {
t.Log(i18n.GetWithName("PullImageStart", image))
}
if pullErr := dockerClient.PullImageWithProcess(t, image); pullErr != nil {
if exists, _ := dockerClient.ImageExists(image); exists {
if t != nil {
t.Log(i18n.GetMsgByKey("UseExistImage"))
}
continue
}
return buserr.WithNameAndErr("ErrDockerPullImage", "", pullErr)
}
}
exists, inspectErr := dockerClient.ImageExists(image)
if inspectErr != nil || !exists {
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s is not available locally: %v", image, inspectErr))
}
if pull && t != nil {
t.LogSuccess(i18n.GetMsgByKey("PullImage"))
}
}
return nil
}
func verifyUpgradeImages(images []string) error {
dockerClient, err := docker.NewClient()
if err != nil {
return err
}
defer dockerClient.Close()
for _, image := range images {
exists, inspectErr := dockerClient.ImageExists(image)
if inspectErr != nil || !exists {
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s is not available locally: %v", image, inspectErr))
}
}
return nil
}
func renderUpgradeEnv(install *model.AppInstall, original []byte) ([]byte, error) {
originalEnv := make(map[string]string)
if len(original) > 0 {
var err error
originalEnv, err = godotenv.UnmarshalBytes(original)
if err != nil {
return nil, err
}
}
params := make(map[string]string, len(originalEnv))
maps.Copy(params, originalEnv)
envs := make(map[string]interface{})
if err := json.Unmarshal([]byte(install.Env), &envs); err != nil {
return nil, err
}
handleMap(envs, params)
if install.App.Key == "openlist" {
// The upgrade script updates this too late for the pre-pull phase.
image := "openlistteam/openlist:v" + strings.TrimPrefix(install.Version, "v")
if preInstalled := params["PRE_INSTALLED"]; preInstalled != "" {
image += "-" + preInstalled
}
params["OPENLIST_IMAGE"] = image
envs["OPENLIST_IMAGE"] = image
content, err := json.Marshal(envs)
if err != nil {
return nil, err
}
install.Env = string(content)
}
if install.App.Key == constant.AppOpenresty {
for _, key := range []string{"CONTAINER_PACKAGE_URL", "RESTY_ADD_PACKAGE_BUILDDEPS", "RESTY_CONFIG_OPTIONS_MORE"} {
if value, ok := originalEnv[key]; ok {
params[key] = value
}
}
if websiteDir := strings.TrimSpace(originalEnv["WEBSITE_DIR"]); websiteDir != "" {
params["WEBSITE_DIR"] = websiteDir
}
websiteDir := strings.TrimSpace(params["WEBSITE_DIR"])
if websiteDir == "" {
websiteDir = NewISettingService().GetWebsiteDir()
}
if !path.IsAbs(websiteDir) {
websiteDir = path.Join(global.Dir.DataDir, websiteDir)
}
params["WEBSITE_DIR"] = websiteDir
envs["WEBSITE_DIR"] = websiteDir
content, marshalErr := json.Marshal(envs)
if marshalErr != nil {
return nil, marshalErr
}
install.Env = string(content)
}
content, err := godotenv.Marshal(params)
if err != nil {
return nil, err
}
return []byte(content), nil
}
func renderUpgradeCompose(install model.AppInstall, detail model.AppDetail, customCompose string) (string, error) {
if customCompose != "" {
return customCompose, nil
}
if install.App.Key == vllmAppKeyForUpgrade {
return install.DockerCompose, nil
}
return getUpgradeCompose(install, detail)
}
func writeUpgradeFile(filePath string, content []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(path.Dir(filePath), "."+path.Base(filePath)+".*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err = tmp.Chmod(mode); err == nil {
_, err = tmp.Write(content)
}
if err == nil {
err = tmp.Sync()
}
if closeErr := tmp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(tmpPath, filePath)
}
func copyUpgradeStageFile(sourceRoot, targetRoot, relativePath string) error {
source := path.Join(sourceRoot, relativePath)
if _, err := os.Stat(source); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
target := path.Join(targetRoot, relativePath)
_ = os.RemoveAll(target)
return copyOpenrestyUpgradeSnapshotEntry(source, target)
}
func replaceUpgradePath(sourceRoot, targetRoot, relativePath string) error {
source := path.Join(sourceRoot, relativePath)
if _, err := os.Stat(source); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
target := path.Join(targetRoot, relativePath)
if err := os.RemoveAll(target); err != nil {
return err
}
return copyOpenrestyUpgradeSnapshotEntry(source, target)
}
func createUpgradeFileSnapshot(installPath string, paths []string) (*upgradeFileSnapshot, error) {
backupPath, err := os.MkdirTemp("", "1panel-app-upgrade-*")
if err != nil {
return nil, err
}
snapshot := &upgradeFileSnapshot{
installPath: installPath,
backupPath: backupPath,
paths: paths,
existing: make(map[string]bool, len(paths)),
}
for _, relativePath := range paths {
source := path.Join(installPath, relativePath)
if _, err = os.Stat(source); err != nil {
if os.IsNotExist(err) {
continue
}
snapshot.Cleanup()
return nil, err
}
snapshot.existing[relativePath] = true
if err = copyOpenrestyUpgradeSnapshotEntry(source, path.Join(backupPath, relativePath)); err != nil {
snapshot.Cleanup()
return nil, err
}
}
return snapshot, nil
}
func (s *upgradeFileSnapshot) Restore() error {
for _, relativePath := range s.paths {
target := path.Join(s.installPath, relativePath)
if err := os.RemoveAll(target); err != nil {
return err
}
if !s.existing[relativePath] {
continue
}
if err := copyOpenrestyUpgradeSnapshotEntry(path.Join(s.backupPath, relativePath), target); err != nil {
return err
}
}
return nil
}
func (s *upgradeFileSnapshot) Cleanup() {
if s != nil && s.backupPath != "" {
_ = os.RemoveAll(s.backupPath)
}
}
type appContainerReadinessClient interface {
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)
if err != nil {
return nil, err
}
expectedServices := make(map[string]struct{})
for _, service := range project.Services {
if !skipCheckStatus(service) {
expectedServices[service.Name] = struct{}{}
}
}
if len(expectedServices) == 0 {
return strings.Split(install.ContainerName, ","), nil
}
options := container.ListOptions{
All: true,
Filters: filters.NewArgs(
filters.Arg("label", composeWorkdirLabel+"="+install.GetPath()),
),
}
containers, err := client.ContainerList(ctx, options)
if err != nil {
return nil, err
}
foundServices := make(map[string]bool, len(expectedServices))
containerNames := make([]string, 0, len(containers))
for _, item := range containers {
serviceName := item.Labels[composeServiceLabel]
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], "/"))
}
}
for serviceName := range expectedServices {
if !foundServices[serviceName] {
return nil, fmt.Errorf("container for service %s was not created", serviceName)
}
}
sort.Strings(containerNames)
return containerNames, nil
}
+271 -371
View File
@@ -1,7 +1,6 @@
package service package service
import ( import (
"bufio"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@@ -10,7 +9,6 @@ import (
"math" "math"
"net/http" "net/http"
"os" "os"
"os/exec"
"path" "path"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -89,7 +87,15 @@ func checkPort(key string, params map[string]interface{}) (int, error) {
return 0, nil return 0, nil
} }
func isPortInUse(port int, protocol string) bool {
return common.ScanPortWithProto(port, normalizeComposeProtocol(protocol))
}
func checkPortExist(port int) error { func checkPortExist(port int) error {
return checkPortExistWithProtocol(port, "")
}
func checkPortExistWithProtocol(port int, protocol string) error {
errMap := make(map[string]interface{}) errMap := make(map[string]interface{})
errMap["port"] = port errMap["port"] = port
appInstall, _ := appInstallRepo.GetFirst(appInstallRepo.WithPort(port)) appInstall, _ := appInstallRepo.GetFirst(appInstallRepo.WithPort(port))
@@ -110,7 +116,7 @@ func checkPortExist(port int) error {
errMap["name"] = domain.Domain errMap["name"] = domain.Domain
return buserr.WithMap("ErrPortExist", errMap, nil) return buserr.WithMap("ErrPortExist", errMap, nil)
} }
if common.ScanPort(port) { if isPortInUse(port, protocol) {
return buserr.WithDetail("ErrPortInUsed", port, nil) return buserr.WithDetail("ErrPortInUsed", port, nil)
} }
return nil return nil
@@ -291,8 +297,8 @@ func createLink(ctx context.Context, installTask *task.Task, app model.App, appI
} }
resourceId = oldMysqlDb.ID resourceId = oldMysqlDb.ID
if oldMysqlDb.ID > 0 { if oldMysqlDb.ID > 0 {
if oldMysqlDb.Username != dbConfig.DbUser || oldMysqlDb.Password != dbConfig.Password { if err := ensureAppMysqlDBUser(database, dbConfig); err != nil {
return buserr.New("ErrDbUserNotValid") return err
} }
} else { } else {
var createMysql dto.MysqlDBCreate var createMysql dto.MysqlDBCreate
@@ -347,43 +353,29 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
logStr := i18n.GetMsgByKey("Stop") + i18n.GetMsgByKey("App") logStr := i18n.GetMsgByKey("Stop") + i18n.GetMsgByKey("App")
t.Log(logStr) t.Log(logStr)
out, err := compose.Down(install.GetComposePath()) if deleteReq.UseLifecycleScripts {
if err != nil && !deleteReq.ForceDelete { if err = runScript(t, &install, "uninstall"); err != nil {
return handleErr(install, err, out) return err
}
} else {
out, err := compose.Down(install.GetComposePath())
if err != nil && !deleteReq.ForceDelete {
return handleErr(install, err, out)
}
if err = runScript(t, &install, "uninstall"); err != nil {
_, _ = compose.Up(install.GetComposePath())
return err
}
} }
t.LogSuccess(logStr) t.LogSuccess(logStr)
if err = runScript(t, &install, "uninstall"); err != nil {
_, _ = compose.Up(install.GetComposePath())
return err
}
if deleteReq.DeleteImage { if deleteReq.DeleteImage {
delImageStr := i18n.GetMsgByKey("TaskDelete") + i18n.GetMsgByKey("Image")
content, err := op.GetContent(install.GetEnvPath()) content, err := op.GetContent(install.GetEnvPath())
if err != nil { if err != nil {
return err return err
} }
images, err := docker.GetImagesFromDockerCompose(content, []byte(install.DockerCompose)) if err = deleteAppImagesByCompose(t, content, []byte(install.DockerCompose), nil); err != nil {
if err != nil {
return err return err
} }
client, err := docker.NewClient()
if err != nil {
return err
}
defer client.Close()
for _, image := range images {
imageID, err := client.GetImageIDByName(image)
if err == nil {
imgStr := delImageStr + image
t.Log(imgStr)
if err = client.DeleteImage(imageID); err != nil {
t.LogFailedWithErr(imgStr, err)
continue
}
t.LogSuccess(delImageStr + image)
}
}
} }
} }
tx, ctx := helper.GetTxAndContext() tx, ctx := helper.GetTxAndContext()
@@ -424,6 +416,20 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
switch install.App.Key { switch install.App.Key {
case constant.AppMysql, constant.AppMariaDB, constant.AppMysqlCluster: case constant.AppMysql, constant.AppMariaDB, constant.AppMysqlCluster:
if err = databaseUserGrantRepo.DeleteBy(
ctx,
repo.WithByType(install.App.Key),
databaseUserGrantRepo.WithByDatabase(install.Name),
); err != nil {
return err
}
if err = databaseUserRepo.DeleteBy(
ctx,
repo.WithByType(install.App.Key),
databaseUserRepo.WithByDatabase(install.Name),
); err != nil {
return err
}
_ = mysqlRepo.Delete(ctx, mysqlRepo.WithByMysqlName(install.Name)) _ = mysqlRepo.Delete(ctx, mysqlRepo.WithByMysqlName(install.Name))
case constant.AppMongodb: case constant.AppMongodb:
_ = mongodbRepo.Delete(ctx, mongodbRepo.WithByMongodbName(install.Name)) _ = mongodbRepo.Delete(ctx, mongodbRepo.WithByMongodbName(install.Name))
@@ -468,6 +474,107 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
return nil return nil
} }
type appImageID struct {
name string
id string
}
func getAppImageIDsByCompose(client docker.Client, envContent, composeContent []byte) ([]appImageID, error) {
images, err := docker.GetImagesFromDockerCompose(envContent, composeContent)
if err != nil {
return nil, err
}
imageIDs := make([]appImageID, 0, len(images))
for _, image := range images {
imageID, err := client.GetImageIDByName(image)
if err == nil && imageID != "" {
imageIDs = append(imageIDs, appImageID{name: image, id: imageID})
}
}
return imageIDs, nil
}
func deleteAppImagesByCompose(t *task.Task, envContent, composeContent []byte, excludeImages []string) error {
client, err := docker.NewClient()
if err != nil {
return err
}
defer client.Close()
imageIDs, err := getAppImageIDsByCompose(client, envContent, composeContent)
if err != nil {
return err
}
return deleteAppImagesByIDs(t, client, imageIDs, excludeImages)
}
func deleteAppImagesByIDs(t *task.Task, client docker.Client, imageIDs []appImageID, excludeImages []string) error {
delImageStr := i18n.GetMsgByKey("TaskDelete") + i18n.GetMsgByKey("Image")
excludeImageIDs := make(map[string]struct{}, len(excludeImages))
for _, image := range excludeImages {
imageID, err := client.GetImageIDByName(image)
if err == nil && imageID != "" {
excludeImageIDs[imageID] = struct{}{}
}
}
deletedImageIDs := make(map[string]struct{}, len(imageIDs))
for _, image := range imageIDs {
if _, ok := excludeImageIDs[image.id]; ok {
continue
}
if _, ok := deletedImageIDs[image.id]; ok {
continue
}
deletedImageIDs[image.id] = struct{}{}
imgStr := delImageStr + image.name
t.Log(imgStr)
if err := client.DeleteImage(image.id); err != nil {
t.LogFailedWithErr(imgStr, err)
continue
}
t.LogSuccess(imgStr)
}
return nil
}
func ensureAppMysqlDBUser(database model.Database, dbConfig dto.AppDatabase) error {
const host = "%"
mysqlService := NewIMysqlService()
users, err := mysqlService.ListUsers(dto.MysqlUserSearch{Database: database.Name})
if err != nil {
return err
}
userExists := false
passwordValid := false
for _, user := range users {
if user.Username != dbConfig.DbUser || user.Host != host || user.IsDelete {
continue
}
userExists = true
passwordValid = user.Password == dbConfig.Password
break
}
if !userExists || !passwordValid {
return buserr.New("ErrDbUserNotValid")
}
grants, err := mysqlService.ListGrants(dto.MysqlUserSearch{Database: database.Name})
if err != nil {
return err
}
for _, grant := range grants {
if grant.Database == dbConfig.DbName && grant.Username == dbConfig.DbUser && grant.Host == host {
return nil
}
}
return mysqlService.GrantUser(dto.MysqlGrantCreate{
Database: database.Name,
DB: dbConfig.DbName,
Username: dbConfig.DbUser,
Host: host,
})
}
func deleteLink(del dto.DelAppLink) error { func deleteLink(del dto.DelAppLink) error {
install := del.Install install := del.Install
resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(install.ID)) resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(install.ID))
@@ -477,19 +584,20 @@ func deleteLink(del dto.DelAppLink) error {
for _, re := range resources { for _, re := range resources {
switch re.Key { switch re.Key {
case constant.AppMysql, constant.AppMariaDB: case constant.AppMysql, constant.AppMariaDB:
mysqlService := NewIMysqlService()
database, _ := mysqlRepo.Get(repo.WithByID(re.ResourceId)) database, _ := mysqlRepo.Get(repo.WithByID(re.ResourceId))
if reflect.DeepEqual(database, model.DatabaseMysql{}) { if reflect.DeepEqual(database, model.DatabaseMysql{}) {
continue continue
} }
if err := mysqlService.Delete(del.Ctx, dto.MysqlDBDelete{ if err := deleteMysqlDatabaseForResourceOwner(del.Ctx, dto.MysqlDBDelete{
ID: database.ID, ID: database.ID,
ForceDelete: del.ForceDelete, ForceDelete: del.ForceDelete,
DeleteBackup: true, DeleteBackup: true,
Type: re.Key, Type: re.Key,
Database: database.MysqlName, Database: database.MysqlName,
}); err != nil && !del.ForceDelete { }, dto.DBResource{Type: constant.TypeApp, Name: install.Name}); err != nil {
return err if isMysqlDatabaseResourceInUseError(err) || !del.ForceDelete {
return err
}
} }
case constant.AppPostgresql: case constant.AppPostgresql:
pgsqlService := NewIPostgresqlService() pgsqlService := NewIPostgresqlService()
@@ -564,11 +672,58 @@ func handleUpgradeCompose(install model.AppInstall, detail model.AppDetail) (map
if oldServiceValue["restart"] != nil { if oldServiceValue["restart"] != nil {
serviceValue["restart"] = oldServiceValue["restart"] serviceValue["restart"] = oldServiceValue["restart"]
} }
if install.App.Key == constant.AppOpenresty {
mergeOpenrestyModuleVolumes(serviceValue, oldServiceValue)
}
servicesMap[install.ServiceName] = serviceValue servicesMap[install.ServiceName] = serviceValue
composeMap["services"] = servicesMap composeMap["services"] = servicesMap
return composeMap, nil return composeMap, nil
} }
// mergeOpenrestyModuleVolumes carries the dynamic module mounts of the old
// compose over to the upgraded one when it does not declare them, so built
// module artifacts and their load configuration stay mounted across upgrades.
func mergeOpenrestyModuleVolumes(serviceValue, oldServiceValue map[string]interface{}) {
oldVolumes, ok := oldServiceValue["volumes"].([]interface{})
if !ok {
return
}
newVolumes, _ := serviceValue["volumes"].([]interface{})
existing := make(map[string]struct{}, len(newVolumes))
for _, volume := range newVolumes {
if containerPath, ok := composeVolumeContainerPath(volume); ok {
existing[containerPath] = struct{}{}
}
}
for _, volume := range oldVolumes {
containerPath, ok := composeVolumeContainerPath(volume)
if !ok {
continue
}
if !strings.Contains(containerPath, nginxModuleEnabledConfDir) && !strings.Contains(containerPath, "nginx/modules/1panel") {
continue
}
if _, ok = existing[containerPath]; ok {
continue
}
newVolumes = append(newVolumes, volume)
existing[containerPath] = struct{}{}
}
serviceValue["volumes"] = newVolumes
}
func composeVolumeContainerPath(volume interface{}) (string, bool) {
volumeStr, ok := volume.(string)
if !ok {
return "", false
}
parts := strings.Split(volumeStr, ":")
if len(parts) < 2 {
return "", false
}
return parts[1], true
}
func getUpgradeCompose(install model.AppInstall, detail model.AppDetail) (string, error) { func getUpgradeCompose(install model.AppInstall, detail model.AppDetail) (string, error) {
if detail.DockerCompose == "" { if detail.DockerCompose == "" {
return "", nil return "", nil
@@ -602,331 +757,35 @@ func getUpgradeCompose(install model.AppInstall, detail model.AppDetail) (string
return string(composeByte), nil return string(composeByte), nil
} }
func buildNginx(parentTask *task.Task) error { func buildNginx(parentTask *task.Task, nginxInstall model.AppInstall, catalogPath string) error {
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
buildPath := path.Join(nginxInstall.GetPath(), "build") buildPath := path.Join(nginxInstall.GetPath(), nginxModuleBuildDir)
if !fileOp.Stat(buildPath) { if !fileOp.Stat(buildPath) {
return buserr.New("ErrBuildDirNotFound") return buserr.New("ErrBuildDirNotFound")
} }
moduleConfigPath := path.Join(buildPath, "module.json") modules, err := loadNginxModulesWithCatalog(nginxInstall, catalogPath)
moduleContent, err := fileOp.GetContent(moduleConfigPath)
if err != nil { if err != nil {
return err return err
} }
var ( previousModules := cloneNginxModules(modules)
modules []dto.NginxModule staticBuild := hasEnabledStaticNginxModules(modules)
addModuleParams []string if err = configureStaticNginxModules(nginxInstall, modules, ""); err != nil {
addPackages []string return err
)
if len(moduleContent) > 0 {
_ = json.Unmarshal(moduleContent, &modules)
bashFile, err := os.OpenFile(path.Join(buildPath, "tmp", "pre.sh"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, constant.DirPerm)
if err != nil {
return err
}
defer bashFile.Close()
bashFileWriter := bufio.NewWriter(bashFile)
for _, module := range modules {
if !module.Enable {
continue
}
_, err = bashFileWriter.WriteString(module.Script + "\n")
if err != nil {
return err
}
addModuleParams = append(addModuleParams, module.Params)
addPackages = append(addPackages, module.Packages...)
}
err = bashFileWriter.Flush()
if err != nil {
return err
}
} }
envs, err := gotenv.Read(nginxInstall.GetEnvPath()) if staticBuild {
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image"))
parentTask.LogStart(logStr)
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(120*time.Minute))
if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil {
return err
}
parentTask.LogSuccess(logStr)
}
modules, err = buildDynamicNginxModules(nginxInstall, modules, nil, false, "", catalogPath, parentTask)
if err != nil { if err != nil {
return err return err
} }
envs["RESTY_CONFIG_OPTIONS_MORE"] = "" return commitNginxModuleBuilds(nginxInstall, previousModules, modules, false, catalogPath)
envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = ""
if len(addModuleParams) > 0 {
envs["RESTY_CONFIG_OPTIONS_MORE"] = strings.Join(addModuleParams, " ")
}
if len(addPackages) > 0 {
envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = strings.Join(addPackages, " ")
}
_ = gotenv.Write(envs, nginxInstall.GetEnvPath())
if len(addModuleParams) == 0 && len(addPackages) == 0 {
return nil
}
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image"))
parentTask.LogStart(logStr)
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(60*time.Minute))
if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil {
return err
}
parentTask.LogSuccess(logStr)
return nil
}
func upgradeInstall(req request.AppInstallUpgrade) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if err != nil {
return err
}
oldVersion := install.Version
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil {
return err
}
if install.App.Key == vllmAppKeyForUpgrade && !isVllmUpgradeVersionAllowed(install.Version, detail.Version, loadVllmImageFromEnv(install.Env)) {
return errors.New("vLLM can only upgrade within the same image type")
}
if install.Version == detail.Version {
return errors.New("two version is same")
}
upgradeTask, err := task.NewTaskWithOps(install.Name, task.TaskUpgrade, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil {
return err
}
install.Status = constant.StatusUpgrading
var (
upErr error
backupFile string
)
backUpApp := func(t *task.Task) error {
backupService := NewIBackupService()
backupRecordService := NewIBackupRecordService()
fileName := fmt.Sprintf("upgrade_backup_%s_%s.tar.gz", install.Name, time.Now().Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5))
backupRecord, err := backupService.AppBackup(dto.CommonBackup{Name: install.App.Key, DetailName: install.Name, FileName: fileName})
if err == nil {
backups, _ := backupRecordService.ListAppRecords(install.App.Key, install.Name, "upgrade_backup")
if len(backups) > 3 {
backupsToDelete := backups[:len(backups)-3]
var deleteIDs []uint
for _, backup := range backupsToDelete {
deleteIDs = append(deleteIDs, backup.ID)
}
_ = backupRecordService.BatchDeleteRecord(deleteIDs)
}
backupFile = path.Join(global.Dir.LocalBackupDir, backupRecord.FileDir, backupRecord.FileName)
} else {
return buserr.WithNameAndErr("ErrAppBackup", install.Name, err)
}
return nil
}
if req.Backup {
upgradeTask.AddSubTask(task.GetTaskName(install.Name, task.TaskBackup, task.TaskScopeApp), backUpApp, nil)
}
upgradeApp := func(t *task.Task) error {
fileOp := files.NewFileOp()
detailDir := path.Join(global.Dir.ResourceDir, "apps", install.App.Resource, install.App.Key, detail.Version)
if install.App.Resource == constant.AppResourceRemote {
if err = downloadApp(install.App, detail, &install, t.Logger); err != nil {
return err
}
if detail.DockerCompose == "" {
composeDetail, err := fileOp.GetContent(path.Join(detailDir, "docker-compose.yml"))
if err != nil {
return err
}
detail.DockerCompose = string(composeDetail)
_ = appDetailRepo.Update(context.Background(), detail)
}
go func() {
RequestDownloadCallBack(detail.DownloadCallBackUrl)
}()
}
if install.App.Resource == constant.AppResourceLocal {
detailDir = path.Join(global.Dir.ResourceDir, "apps", "local", strings.TrimPrefix(install.App.Key, "local"), detail.Version)
}
content, err := fileOp.GetContent(install.GetEnvPath())
if err != nil {
return err
}
if install.App.Key == vllmAppKeyForUpgrade {
envs := make(map[string]interface{})
if err = json.Unmarshal([]byte(install.Env), &envs); err != nil {
return err
}
image := buildVllmUpgradeImage(loadVllmImageFromEnv(install.Env), oldVersion, detail.Version)
envs[vllmImageEnvKey] = image
paramByte, err := json.Marshal(envs)
if err != nil {
return err
}
install.Env = string(paramByte)
content = setVllmImageInEnvContent(content, image)
}
if req.PullImage {
composeContent := []byte(detail.DockerCompose)
if install.App.Key == vllmAppKeyForUpgrade {
composeContent = []byte(install.DockerCompose)
}
if req.DockerCompose != "" {
composeContent = []byte(req.DockerCompose)
}
images, err := docker.GetImagesFromDockerCompose(content, composeContent)
if err != nil {
return err
}
dockerCLi, err := docker.NewClient()
if err != nil {
return err
}
defer dockerCLi.Close()
for _, image := range images {
t.Log(i18n.GetWithName("PullImageStart", image))
if err = dockerCLi.PullImageWithProcess(t, image); err != nil {
err = buserr.WithNameAndErr("ErrDockerPullImage", "", err)
return err
}
t.LogSuccess(i18n.GetMsgByKey("PullImage"))
}
}
_ = copyAppDetailMissing(fileOp, detailDir, install.GetPath())
if install.App.Key == constant.AppOpenresty {
installBuildDir := path.Join(install.GetPath(), "build")
detailBuildDir := path.Join(detailDir, "build")
if !fileOp.Stat(installBuildDir) {
if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil {
return err
}
}
if err := fileOp.DeleteDir(path.Join(installBuildDir, "tmp")); err != nil {
return err
}
if err := fileOp.CopyDir(path.Join(detailBuildDir, "tmp"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "Dockerfile"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.conf"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.vh.default.conf"), installBuildDir); err != nil {
return err
}
}
sourceScripts := path.Join(detailDir, "scripts")
if fileOp.Stat(sourceScripts) {
dstScripts := path.Join(install.GetPath(), "scripts")
_ = fileOp.DeleteDir(dstScripts)
_ = fileOp.CreateDir(dstScripts, constant.DirPerm)
scriptCmd := exec.Command("cp", "-rf", sourceScripts+"/.", dstScripts+"/")
_, _ = scriptCmd.CombinedOutput()
}
var newCompose string
if err = migrateOpenclawProtocolUpgrade(&install, oldVersion, detail.Version); err != nil {
return err
}
if req.DockerCompose == "" {
if install.App.Key == vllmAppKeyForUpgrade {
newCompose = install.DockerCompose
} else {
newCompose, err = getUpgradeCompose(install, detail)
if err != nil {
return err
}
}
} else {
newCompose = req.DockerCompose
}
install.DockerCompose = newCompose
install.Version = detail.Version
install.AppDetailId = req.DetailID
if out, err := compose.Down(install.GetComposePath()); err != nil {
if out != "" {
upErr = errors.New(out)
return upErr
}
return err
}
envs := make(map[string]interface{})
if err = json.Unmarshal([]byte(install.Env), &envs); err != nil {
return err
}
envParams := make(map[string]string, len(envs))
if install.App.Key == constant.AppOpenresty {
packageUrl, _ := env.GetEnvValueByKey(install.GetEnvPath(), "CONTAINER_PACKAGE_URL")
addPackage, _ := env.GetEnvValueByKey(install.GetEnvPath(), "RESTY_ADD_PACKAGE_BUILDDEPS")
options, _ := env.GetEnvValueByKey(install.GetEnvPath(), "RESTY_CONFIG_OPTIONS_MORE")
envParams["CONTAINER_PACKAGE_URL"] = packageUrl
envParams["RESTY_ADD_PACKAGE_BUILDDEPS"] = addPackage
envParams["RESTY_CONFIG_OPTIONS_MORE"] = options
}
handleMap(envs, envParams)
if err = env.Write(envParams, install.GetEnvPath()); err != nil {
return err
}
if err = runScript(t, &install, "upgrade"); err != nil {
return err
}
if err = fileOp.WriteFile(install.GetComposePath(), strings.NewReader(install.DockerCompose), constant.FilePerm); err != nil {
return err
}
if install.App.Key == constant.AppOpenresty {
if err = buildNginx(t); err != nil {
t.Log(err.Error())
return err
}
}
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
t.Log(logStr)
if out, err := compose.Up(install.GetComposePath()); err != nil {
if out != "" {
return errors.New(out)
}
return err
}
t.LogSuccess(logStr)
install.Status = constant.StatusRunning
return appInstallRepo.Save(context.Background(), &install)
}
rollBackApp := func(t *task.Task) {
if req.Backup {
t.Log(i18n.GetWithName("AppRecover", install.Name))
if err := NewIBackupService().AppRecover(dto.CommonRecover{Name: install.App.Key, DetailName: install.Name, Type: "app", DownloadAccountID: 1, File: backupFile}); err != nil {
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), err)
return
}
t.LogSuccess(i18n.GetWithName("AppRecover", install.Name))
return
}
}
upgradeTask.AddSubTaskWithOps(task.GetTaskName(install.Name, task.TaskUpgrade, task.TaskScopeApp), upgradeApp, rollBackApp, 0, 1*time.Hour)
go func() {
err = upgradeTask.Execute()
if err != nil {
existInstall, _ := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if existInstall.ID > 0 && existInstall.Status != constant.StatusRunning {
existInstall.Status = constant.StatusUpgradeErr
existInstall.Message = err.Error()
_ = appInstallRepo.Save(context.Background(), &existInstall)
}
}
}()
return appInstallRepo.Save(context.Background(), &install)
} }
func skipCheckStatus(service types.ServiceConfig) bool { func skipCheckStatus(service types.ServiceConfig) bool {
@@ -1146,6 +1005,12 @@ func runScript(task *task.Task, appInstall *model.AppInstall, operate string) er
scriptPath = path.Join(workDir, "scripts", "upgrade.sh") scriptPath = path.Join(workDir, "scripts", "upgrade.sh")
case "uninstall": case "uninstall":
scriptPath = path.Join(workDir, "scripts", "uninstall.sh") scriptPath = path.Join(workDir, "scripts", "uninstall.sh")
case "start":
scriptPath = path.Join(workDir, "scripts", "start.sh")
case "stop":
scriptPath = path.Join(workDir, "scripts", "stop.sh")
case "restart":
scriptPath = path.Join(workDir, "scripts", "restart.sh")
} }
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(scriptPath) { if !fileOp.Stat(scriptPath) {
@@ -1155,7 +1020,11 @@ func runScript(task *task.Task, appInstall *model.AppInstall, operate string) er
logStr := i18n.GetWithName("ExecShell", operate) logStr := i18n.GetWithName("ExecShell", operate)
task.LogStart(logStr) task.LogStart(logStr)
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(10*time.Minute), cmd.WithWorkDir(workDir)) timeout := 10 * time.Minute
if operate == "start" || operate == "restart" {
timeout = time.Hour
}
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(timeout), cmd.WithWorkDir(workDir), cmd.WithTask(*task))
if err := cmdMgr.Run("bash", scriptPath); err != nil { if err := cmdMgr.Run("bash", scriptPath); err != nil {
task.LogFailedWithErr(logStr, err) task.LogFailedWithErr(logStr, err)
return err return err
@@ -1190,12 +1059,15 @@ func checkContainerNameIsExist(containerName, appDir string) (bool, error) {
return false, nil return false, nil
} }
func upApp(task *task.Task, appInstall *model.AppInstall, pullImages bool) error { func upApp(task *task.Task, appInstall *model.AppInstall, pullImages, useLifecycleScripts bool) error {
upProject := func(appInstall *model.AppInstall) (err error) { upProject := func(appInstall *model.AppInstall) (err error) {
var ( var (
out string out string
errMsg string errMsg string
) )
if useLifecycleScripts {
return runScript(task, appInstall, "start")
}
if pullImages && appInstall.App.Type != "php" { if pullImages && appInstall.App.Type != "php" {
envByte, err := files.NewFileOp().GetContent(appInstall.GetEnvPath()) envByte, err := files.NewFileOp().GetContent(appInstall.GetEnvPath())
if err != nil { if err != nil {
@@ -1522,7 +1394,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 installed.Status == constant.StatusInstalling || installed.Status == constant.StatusRebuilding || installed.Status == constant.StatusUpgrading ||
installed.Status == constant.StatusSyncing || installed.Status == constant.StatusUninstalling || installed.Status == constant.StatusInstallErr installed.Status == constant.StatusSyncing || installed.Status == constant.StatusUninstalling || installed.Status == constant.StatusInstallErr ||
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) {
@@ -1624,6 +1497,9 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
synAppInstall(containersMap, &installed, false) synAppInstall(containersMap, &installed, false)
} }
resourceKeys := getAppInstallResourceKeys(installed.ID)
envMap := make(map[string]interface{})
_ = json.Unmarshal([]byte(installed.Env), &envMap)
installDTO := response.AppInstallDTO{ installDTO := response.AppInstallDTO{
ID: installed.ID, ID: installed.ID,
Name: installed.Name, Name: installed.Name,
@@ -1645,14 +1521,16 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
Website: installed.App.Website, Website: installed.App.Website,
Document: installed.App.Document, Document: installed.App.Document,
}, },
Favorite: installed.Favorite, Favorite: installed.Favorite,
SortOrder: installed.SortOrder, SortOrder: installed.SortOrder,
Container: installed.ContainerName, Container: installed.ContainerName,
ServiceName: strings.ToLower(installed.ServiceName), ServiceName: strings.ToLower(installed.ServiceName),
ResourceKeys: resourceKeys,
Env: envMap,
} }
if !updated && !checkUpdate { if !updated && !checkUpdate {
installDTO.LinkDB = hasLinkDB(installed.ID) installDTO.LinkDB = hasLinkDBFromKeys(resourceKeys)
res = append(res, installDTO) res = append(res, installDTO)
continue continue
} }
@@ -1660,7 +1538,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
if installed.Version == "latest" { if installed.Version == "latest" {
if checkUpdate { if checkUpdate {
installDTO.CanUpdate = false installDTO.CanUpdate = false
installDTO.LinkDB = hasLinkDB(installed.ID) installDTO.LinkDB = hasLinkDBFromKeys(resourceKeys)
res = append(res, installDTO) res = append(res, installDTO)
} }
continue continue
@@ -1689,7 +1567,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
if len(versions) == 0 { if len(versions) == 0 {
if checkUpdate { if checkUpdate {
installDTO.CanUpdate = false installDTO.CanUpdate = false
installDTO.LinkDB = hasLinkDB(installed.ID) installDTO.LinkDB = hasLinkDBFromKeys(resourceKeys)
res = append(res, installDTO) res = append(res, installDTO)
} }
continue continue
@@ -1721,7 +1599,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
res = append(res, installDTO) res = append(res, installDTO)
} }
} else if checkUpdate { } else if checkUpdate {
installDTO.LinkDB = hasLinkDB(installed.ID) installDTO.LinkDB = hasLinkDBFromKeys(resourceKeys)
res = append(res, installDTO) res = append(res, installDTO)
} }
} }
@@ -1964,6 +1842,10 @@ func isHostModel(dockerCompose string) bool {
} }
func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error { func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
return copyAppDetailMissingTracked(fileOp, srcDir, dstDir, nil)
}
func copyAppDetailMissingTracked(fileOp files.FileOp, srcDir, dstDir string, createdPaths *[]string) error {
entries, err := os.ReadDir(srcDir) entries, err := os.ReadDir(srcDir)
if err != nil { if err != nil {
return err return err
@@ -1975,6 +1857,9 @@ func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
srcPath := path.Join(srcDir, entry.Name()) srcPath := path.Join(srcDir, entry.Name())
dstPath := path.Join(dstDir, entry.Name()) dstPath := path.Join(dstDir, entry.Name())
if !fileOp.Stat(dstPath) { if !fileOp.Stat(dstPath) {
if createdPaths != nil {
*createdPaths = append(*createdPaths, dstPath)
}
if entry.IsDir() { if entry.IsDir() {
if err := fileOp.CopyDir(srcPath, dstDir); err != nil { if err := fileOp.CopyDir(srcPath, dstDir); err != nil {
return err return err
@@ -1989,7 +1874,7 @@ func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
if !entry.IsDir() { if !entry.IsDir() {
continue continue
} }
if err := copyAppDetailMissing(fileOp, srcPath, dstPath); err != nil { if err := copyAppDetailMissingTracked(fileOp, srcPath, dstPath, createdPaths); err != nil {
return err return err
} }
} }
@@ -2152,7 +2037,7 @@ func handleOpenrestyFile(appInstall *model.AppInstall) error {
func handleDefaultServer(appInstall *model.AppInstall) error { func handleDefaultServer(appInstall *model.AppInstall) error {
installDir := appInstall.GetPath() installDir := appInstall.GetPath()
defaultConfigPath := path.Join(installDir, "conf", "default", "00.default.conf") defaultConfigPath := path.Join(installDir, nginxModuleConfDir, "default", "00.default.conf")
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
content, err := fileOp.GetContent(defaultConfigPath) content, err := fileOp.GetContent(defaultConfigPath)
if err != nil { if err != nil {
@@ -2166,7 +2051,7 @@ func handleDefaultServer(appInstall *model.AppInstall) error {
} }
func handleSSLConfig(appInstall *model.AppInstall, hasDefaultWebsite bool, sslRejectHandshake bool) error { func handleSSLConfig(appInstall *model.AppInstall, hasDefaultWebsite bool, sslRejectHandshake bool) error {
sslDir := path.Join(appInstall.GetPath(), "conf", "ssl") sslDir := path.Join(appInstall.GetPath(), nginxModuleConfDir, "ssl")
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(sslDir) { if !fileOp.Stat(sslDir) {
return errors.New("ssl dir not found") return errors.New("ssl dir not found")
@@ -2196,7 +2081,7 @@ func handleSSLConfig(appInstall *model.AppInstall, hasDefaultWebsite bool, sslRe
_ = NewIWebsiteSSLService().Delete([]uint{websiteSSL.ID}) _ = NewIWebsiteSSLService().Delete([]uint{websiteSSL.ID})
}() }()
} }
defaultConfigPath := path.Join(appInstall.GetPath(), "conf", "default", "00.default.conf") defaultConfigPath := path.Join(appInstall.GetPath(), nginxModuleConfDir, "default", "00.default.conf")
content, err := os.ReadFile(defaultConfigPath) content, err := os.ReadFile(defaultConfigPath)
if err != nil { if err != nil {
return err return err
@@ -2288,13 +2173,25 @@ func needsUpdate(localTag *model.Tag, remoteTag dto.Tag, translations string) bo
} }
func hasLinkDB(installID uint) bool { func hasLinkDB(installID uint) bool {
return hasLinkDBFromKeys(getAppInstallResourceKeys(installID))
}
func getAppInstallResourceKeys(installID uint) []string {
resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(installID)) resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(installID))
keys := make([]string, 0, len(resources))
for _, resource := range resources {
keys = append(keys, resource.Key)
}
return keys
}
func hasLinkDBFromKeys(resourceKeys []string) bool {
hasDB := false hasDB := false
if len(resources) > 0 { if len(resourceKeys) > 0 {
for _, resource := range resources { for _, resourceKey := range resourceKeys {
if resource.Key == constant.AppPostgres || resource.Key == constant.AppMysql || if resourceKey == constant.AppPostgres || resourceKey == constant.AppMysql ||
resource.Key == constant.AppMariaDB || resource.Key == constant.AppMysqlCluster || resourceKey == constant.AppMariaDB || resourceKey == constant.AppMysqlCluster ||
resource.Key == constant.AppPostgresql || resource.Key == constant.AppPostgresqlCluster { resourceKey == constant.AppPostgresql || resourceKey == constant.AppPostgresqlCluster {
hasDB = true hasDB = true
break break
} }
@@ -2352,6 +2249,9 @@ func getAppVersions(key string, details []model.AppDetail) []string {
hasLatest := false hasLatest := false
latestVersion := "" latestVersion := ""
for _, detail := range details { for _, detail := range details {
if !canAccessVllmVersion(key, detail.Version) {
continue
}
if key != "mssql" && strings.Contains(detail.Version, "latest") { if key != "mssql" && strings.Contains(detail.Version, "latest") {
hasLatest = true hasLatest = true
latestVersion = detail.Version latestVersion = detail.Version
+78 -6
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"bufio" "bufio"
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -372,7 +373,7 @@ func (u *BackupService) checkBackupConn(backup *model.BackupAccount) (bool, erro
targetPath = strings.TrimPrefix(targetPath, "/") targetPath = strings.TrimPrefix(targetPath, "/")
} }
if _, err := client.Upload(fileItem, targetPath); err != nil { if _, err := client.Upload(context.Background(), fileItem, targetPath); err != nil {
return false, err return false, err
} }
_, _ = client.Delete(path.Join(backup.BackupPath, "test/1panel")) _, _ = client.Delete(path.Join(backup.BackupPath, "test/1panel"))
@@ -437,6 +438,10 @@ type backupClientHelper struct {
} }
func NewBackupClientMap(ids []string) map[string]backupClientHelper { func NewBackupClientMap(ids []string) map[string]backupClientHelper {
return NewBackupClientMapWithContext(context.Background(), ids)
}
func NewBackupClientMapWithContext(ctx context.Context, ids []string) map[string]backupClientHelper {
var accounts []model.BackupAccount var accounts []model.BackupAccount
var idItems []uint var idItems []uint
for i := 0; i < len(ids); i++ { for i := 0; i < len(ids); i++ {
@@ -446,7 +451,7 @@ func NewBackupClientMap(ids []string) map[string]backupClientHelper {
accounts, _ = backupRepo.List(repo.WithByIDs(idItems)) accounts, _ = backupRepo.List(repo.WithByIDs(idItems))
clientMap := make(map[string]backupClientHelper) clientMap := make(map[string]backupClientHelper)
for _, item := range accounts { for _, item := range accounts {
backClient, err := newClient(&item, true) backClient, err := newClientWithContext(ctx, &item, true)
itemHelper := backupClientHelper{ itemHelper := backupClientHelper{
client: backClient, client: backClient,
name: item.Name, name: item.Name,
@@ -463,7 +468,11 @@ func NewBackupClientMap(ids []string) map[string]backupClientHelper {
return clientMap return clientMap
} }
func uploadWithMap(taskItem task.Task, accountMap map[string]backupClientHelper, src, dst, accountIDs string, downloadAccountID, retry uint) error { func uploadWithMap(taskItem task.Task, accountMap map[string]backupClientHelper, src, dst, accountIDs string, downloadAccountID, retry uint, cleanOnFailure bool) error {
return uploadWithMapWithContext(context.Background(), taskItem, accountMap, src, dst, accountIDs, downloadAccountID, retry, cleanOnFailure, true)
}
func uploadWithMapWithContext(ctx context.Context, taskItem task.Task, accountMap map[string]backupClientHelper, src, dst, accountIDs string, downloadAccountID, retry uint, cleanOnFailure, removeSrc bool) error {
accounts := strings.Split(accountIDs, ",") accounts := strings.Split(accountIDs, ",")
for _, account := range accounts { for _, account := range accounts {
if len(account) == 0 { if len(account) == 0 {
@@ -489,10 +498,13 @@ func uploadWithMap(taskItem task.Task, accountMap map[string]backupClientHelper,
"backup": name, "backup": name,
})) }))
for i := 0; i < int(retry)+1; i++ { for i := 0; i < int(retry)+1; i++ {
_, err := itemBackup.client.Upload(src, path.Join(itemBackup.backupPath, dst)) _, err := itemBackup.client.Upload(ctx, src, path.Join(itemBackup.backupPath, dst))
taskItem.LogWithStatus(i18n.GetMsgByKey("Upload"), err) taskItem.LogWithStatus(i18n.GetMsgByKey("Upload"), err)
if err != nil { if err != nil {
if account == fmt.Sprintf("%d", downloadAccountID) { if account == fmt.Sprintf("%d", downloadAccountID) {
if cleanOnFailure {
cleanupCronjobBackupArtifacts(accountMap, src, dst)
}
return err return err
} }
} else { } else {
@@ -502,11 +514,71 @@ func uploadWithMap(taskItem task.Task, accountMap map[string]backupClientHelper,
itemBackup.hasBackup = true itemBackup.hasBackup = true
accountMap[account] = itemBackup accountMap[account] = itemBackup
} }
os.RemoveAll(src) if removeSrc {
os.RemoveAll(src)
}
return nil return nil
} }
func cleanupCronjobBackupArtifacts(accountMap map[string]backupClientHelper, src, dst string) {
if err := os.RemoveAll(src); err != nil {
global.LOG.Errorf("remove failed local cronjob backup file %s failed, err: %v", src, err)
}
for _, account := range accountMap {
if !account.isOk {
continue
}
if _, err := account.client.Delete(path.Join(account.backupPath, dst)); err != nil {
global.LOG.Errorf("remove failed cronjob backup file %s failed, err: %v", dst, err)
}
}
}
func markBackupFailed(recordID uint, backupErr error) {
_ = backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": backupErr.Error()})
record, err := backupRepo.GetRecord(repo.WithByID(recordID))
if err != nil || record.ID == 0 {
global.LOG.Errorf("load failed backup record %d for cleanup failed, err: %v", recordID, err)
return
}
filePath := path.Join(record.FileDir, record.FileName)
if err := os.Remove(path.Join(global.Dir.LocalBackupDir, filePath)); err != nil && !os.IsNotExist(err) {
global.LOG.Errorf("remove failed local backup file %s failed, err: %v", filePath, err)
}
cleaned := make(map[string]struct{})
for _, accountID := range strings.Split(record.SourceAccountIDs, ",") {
if accountID == "" {
continue
}
if _, ok := cleaned[accountID]; ok {
continue
}
cleaned[accountID] = struct{}{}
id, err := strconv.Atoi(accountID)
if err != nil {
global.LOG.Errorf("parse backup account %s for failed backup cleanup failed, err: %v", accountID, err)
continue
}
account, storageClient, err := NewBackupClientWithID(uint(id))
if err != nil {
global.LOG.Errorf("new backup client for failed backup cleanup failed, err: %v", err)
continue
}
if _, err := storageClient.Delete(path.Join(account.BackupPath, filePath)); err != nil {
global.LOG.Errorf("remove failed backup file %s failed, err: %v", filePath, err)
}
}
}
func newClient(account *model.BackupAccount, isEncrypt bool) (cloud_storage.CloudStorageClient, error) { func newClient(account *model.BackupAccount, isEncrypt bool) (cloud_storage.CloudStorageClient, error) {
return newClientWithContext(context.Background(), account, isEncrypt)
}
func newClientWithContext(ctx context.Context, account *model.BackupAccount, isEncrypt bool) (cloud_storage.CloudStorageClient, error) {
varMap := make(map[string]interface{}) varMap := make(map[string]interface{})
if len(account.Vars) != 0 { if len(account.Vars) != 0 {
if err := json.Unmarshal([]byte(account.Vars), &varMap); err != nil { if err := json.Unmarshal([]byte(account.Vars), &varMap); err != nil {
@@ -531,7 +603,7 @@ func newClient(account *model.BackupAccount, isEncrypt bool) (cloud_storage.Clou
varMap["password"] = account.Credential varMap["password"] = account.Credential
} }
client, err := cloud_storage.NewCloudStorageClient(account.Type, varMap) client, err := cloud_storage.NewCloudStorageClientWithContext(ctx, account.Type, varMap)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+107 -10
View File
@@ -63,11 +63,58 @@ func (u *BackupService) AppBackup(req dto.CommonBackup) (*model.BackupRecord, er
return nil, err return nil, err
} }
if err = handleAppBackup(&install, nil, record.ID, backupDir, fileName, "", req.Secret, req.TaskID); err != nil { if !req.IsImmediate {
global.LOG.Errorf("backup app %s failed, err: %v", req.DetailName, err) if err = handleAppBackup(&install, nil, record.ID, backupDir, fileName, "", req.Secret, req.TaskID); err != nil {
return nil, err markBackupFailed(record.ID, err)
global.LOG.Errorf("backup app %s failed, err: %v", req.DetailName, err)
return nil, err
}
return record, nil
} }
backupTask, err := task.NewTaskWithOps(install.Name, task.TaskBackup, task.TaskScopeBackup, req.TaskID, install.ID)
if err != nil {
markBackupFailed(record.ID, err)
record.Status = constant.StatusFailed
record.Message = err.Error()
return nil, err
}
if err = doAppBackup(&install, backupTask, backupDir, fileName, "", req.Secret); err != nil {
markBackupFailed(record.ID, err)
record.Status = constant.StatusFailed
record.Message = err.Error()
return nil, err
}
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusSuccess})
record.Status = constant.StatusSuccess
return record, nil
}
func backupAppWithParentTask(install *model.AppInstall, parentTask *task.Task, fileName string) (*model.BackupRecord, error) {
itemDir := fmt.Sprintf("app/%s/%s", install.App.Key, install.Name)
backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
record := &model.BackupRecord{
Type: "app",
Name: install.App.Key,
DetailName: install.Name,
SourceAccountIDs: "1",
DownloadAccountID: 1,
FileDir: itemDir,
FileName: fileName,
TaskID: parentTask.TaskID,
Status: constant.StatusWaiting,
}
if err := backupRepo.CreateRecord(record); err != nil {
return nil, err
}
if err := handleAppBackup(install, parentTask, record.ID, backupDir, fileName, "", "", parentTask.TaskID); err != nil {
markBackupFailed(record.ID, err)
record.Status = constant.StatusFailed
record.Message = err.Error()
return record, err
}
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusSuccess})
record.Status = constant.StatusSuccess
return record, nil return record, nil
} }
@@ -143,7 +190,7 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
backupTask.AddSubTaskWithOps(task.GetTaskName(install.Name, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return itemHandler() }, nil, 3, time.Hour) backupTask.AddSubTaskWithOps(task.GetTaskName(install.Name, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return itemHandler() }, nil, 3, time.Hour)
go func() { go func() {
if err := backupTask.Execute(); err != nil { if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) markBackupFailed(recordID, err)
return return
} }
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess}) backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
@@ -184,7 +231,11 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
return err return err
} }
defer func() { defer func() {
_, _ = compose.Up(install.GetComposePath()) if isRollback {
_, _ = compose.UpWithoutBuild(install.GetComposePath())
} else {
_, _ = compose.Up(install.GetComposePath())
}
_ = os.RemoveAll(strings.ReplaceAll(recoverFile, ".tar.gz", "")) _ = os.RemoveAll(strings.ReplaceAll(recoverFile, ".tar.gz", ""))
}() }()
@@ -261,7 +312,7 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
if err != nil { if err != nil {
return err return err
} }
newDB, err := reCreateDB(db.ID, database, backupEnvMap) newDB, err := reCreateDB(db.ID, database, backupEnvMap, install.Name)
if err != nil { if err != nil {
return err return err
} }
@@ -389,10 +440,16 @@ func doAppBackup(install *model.AppInstall, parentTask *task.Task, backupDir, fi
return nil return nil
} }
func reCreateDB(dbID uint, database model.Database, envMap map[string]interface{}) (*model.DatabaseMysql, error) { func reCreateDB(dbID uint, database model.Database, envMap map[string]interface{}, appInstallName string) (*model.DatabaseMysql, error) {
mysqlService := NewIMysqlService() mysqlService := NewIMysqlService()
ctx := context.Background() ctx := context.Background()
_ = mysqlService.Delete(ctx, dto.MysqlDBDelete{ID: dbID, Database: database.Name, Type: database.Type, DeleteBackup: false, ForceDelete: true}) if err := deleteMysqlDatabaseForResourceOwner(
ctx,
dto.MysqlDBDelete{ID: dbID, Database: database.Name, Type: database.Type, DeleteBackup: false, ForceDelete: true},
dto.DBResource{Type: constant.TypeApp, Name: appInstallName},
); err != nil {
return nil, err
}
dbInfo := getDBCreateInfoFromEnv(envMap, "utf8mb4") dbInfo := getDBCreateInfoFromEnv(envMap, "utf8mb4")
createDB, err := mysqlService.Create(context.Background(), dto.MysqlDBCreate{ createDB, err := mysqlService.Create(context.Background(), dto.MysqlDBCreate{
@@ -400,17 +457,57 @@ func reCreateDB(dbID uint, database model.Database, envMap map[string]interface{
From: database.From, From: database.From,
Database: database.Name, Database: database.Name,
Format: dbInfo.Format, Format: dbInfo.Format,
Username: dbInfo.User,
Password: dbInfo.Password,
Permission: "%", Permission: "%",
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(dbInfo.User) != 0 {
if err := ensureMysqlDBUser(mysqlService, database, dbInfo); err != nil {
return nil, err
}
}
updateCronjobsDBRef(dbID, createDB.ID) updateCronjobsDBRef(dbID, createDB.ID)
return createDB, nil return createDB, nil
} }
func ensureMysqlDBUser(mysqlService IMysqlService, database model.Database, dbInfo dbRecreateInfo) error {
const host = "%"
users, err := mysqlService.ListUsers(dto.MysqlUserSearch{Database: database.Name})
if err != nil {
return err
}
var oldUser dto.MysqlUser
exists := false
for _, user := range users {
if user.Username == dbInfo.User && user.Host == host && !user.IsDelete {
oldUser = user
exists = true
break
}
}
if exists {
if len(oldUser.Password) != 0 && oldUser.Password != dbInfo.Password {
return buserr.New("ErrDbUserNotValid")
}
} else {
if err := mysqlService.CreateUser(dto.MysqlUserCreate{
Database: database.Name,
Username: dbInfo.User,
Host: host,
Password: dbInfo.Password,
}); err != nil {
return err
}
}
return mysqlService.GrantUser(dto.MysqlGrantCreate{
Database: database.Name,
DB: dbInfo.Name,
Username: dbInfo.User,
Host: host,
})
}
func reCreatePostgresqlDB(dbID uint, database model.Database, envMap map[string]interface{}) (*model.DatabasePostgresql, error) { func reCreatePostgresqlDB(dbID uint, database model.Database, envMap map[string]interface{}) (*model.DatabasePostgresql, error) {
postgresqlService := NewIPostgresqlService() postgresqlService := NewIPostgresqlService()
_ = postgresqlService.Delete(context.Background(), dto.PostgresqlDBDelete{ _ = postgresqlService.Delete(context.Background(), dto.PostgresqlDBDelete{
+2 -2
View File
@@ -98,7 +98,7 @@ func (u *BackupService) ComposeBackup(req dto.CommonBackup) error {
return err return err
} }
if err := handleComposeBackup(req, nil, record.ID, backupDir, fileName); err != nil { if err := handleComposeBackup(req, nil, record.ID, backupDir, fileName); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) markBackupFailed(record.ID, err)
return err return err
} }
return nil return nil
@@ -163,7 +163,7 @@ func handleComposeBackup(req dto.CommonBackup, parentTask *task.Task, recordID u
go func() { go func() {
defer composeCtx.close() defer composeCtx.close()
if err := backupTask.Execute(); err != nil { if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) markBackupFailed(recordID, err)
return return
} }
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess}) backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
+72 -34
View File
@@ -106,7 +106,7 @@ func (u *BackupService) ContainerBackup(req dto.CommonBackup) error {
return err return err
} }
if err := handleContainerBackup(req.Name, nil, record.ID, backupDir, fileName, req.TaskID, req.Secret, req.StopBefore); err != nil { if err := handleContainerBackup(req.Name, nil, record.ID, backupDir, fileName, req.TaskID, req.Secret, req.StopBefore); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) markBackupFailed(record.ID, err)
return err return err
} }
return nil return nil
@@ -158,7 +158,7 @@ func handleContainerBackup(containerName string, parentTask *task.Task, recordID
go func() { go func() {
defer backupCtx.close() defer backupCtx.close()
if err := backupTask.Execute(); err != nil { if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) markBackupFailed(recordID, err)
return return
} }
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess}) backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
@@ -582,6 +582,10 @@ func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.T
if config.Image == "" { if config.Image == "" {
return fmt.Errorf("container image not found in backup file") return fmt.Errorf("container image not found in backup file")
} }
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(recoverCtx.inspectInfo.NetworkSettings, hostConfig)
if err := normalizeContainerEndpointSettings(ctx, recoverCtx.client, networkConf, extraNetworks); err != nil {
return err
}
if !checkImageExist(recoverCtx.client, config.Image) { if !checkImageExist(recoverCtx.client, config.Image) {
if err := pullImages(taskItem, recoverCtx.client, config.Image); err != nil { if err := pullImages(taskItem, recoverCtx.client, config.Image); err != nil {
return err return err
@@ -596,7 +600,7 @@ func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.T
return err return err
} }
createRes, err := createContainerWithOldNetworks(ctx, recoverCtx.client, config, hostConfig, recoverCtx.inspectInfo.NetworkSettings, recoverCtx.targetName) createRes, err := createContainerWithNetworks(ctx, recoverCtx.client, config, hostConfig, networkConf, extraNetworks, recoverCtx.targetName)
if err != nil { if err != nil {
return err return err
} }
@@ -604,44 +608,68 @@ func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.T
return nil return nil
} }
func removeUnsupportedEndpointStaticIPAM(cli *client.Client, primary *network.NetworkingConfig, extras map[string]*network.EndpointSettings) { func normalizeContainerEndpointSettings(ctx context.Context, cli *client.Client, primary *network.NetworkingConfig, extras map[string]*network.EndpointSettings) error {
if cli.NewVersionError(ctx, "1.44", "specify mac-address per network") != nil {
removeEndpointMacAddresses(primary, extras)
}
endpointGroups := []map[string]*network.EndpointSettings{extras}
if primary != nil { if primary != nil {
removeUnsupportedEndpointStaticIPAMFromEndpoints(cli, primary.EndpointsConfig) endpointGroups = append(endpointGroups, primary.EndpointsConfig)
} }
removeUnsupportedEndpointStaticIPAMFromEndpoints(cli, extras) for _, endpoints := range endpointGroups {
for netName, endpoint := range endpoints {
if endpoint == nil || endpoint.IPAMConfig == nil {
continue
}
info, err := cli.NetworkInspect(ctx, netName, network.InspectOptions{})
if err != nil {
return fmt.Errorf("inspect network %s failed: %w", netName, err)
}
if err := validateContainerEndpointStaticIP(netName, info, endpoint); err != nil {
return err
}
}
}
return nil
} }
func removeUnsupportedEndpointStaticIPAMFromEndpoints(cli *client.Client, endpoints map[string]*network.EndpointSettings) { func removeEndpointMacAddresses(primary *network.NetworkingConfig, extras map[string]*network.EndpointSettings) {
for netName, endpoint := range endpoints { if primary != nil {
if endpoint == nil || endpoint.IPAMConfig == nil { for _, endpoint := range primary.EndpointsConfig {
continue if endpoint != nil {
endpoint.MacAddress = ""
}
} }
info, err := cli.NetworkInspect(context.Background(), netName, network.InspectOptions{}) }
if err != nil { for _, endpoint := range extras {
continue if endpoint != nil {
endpoint.MacAddress = ""
} }
removeUnsupportedEndpointStaticIP(netName, info, endpoint)
} }
} }
func removeUnsupportedEndpointStaticIP(netName string, info network.Inspect, endpoint *network.EndpointSettings) { func validateContainerEndpointStaticIP(netName string, info network.Inspect, endpoint *network.EndpointSettings) error {
if endpoint == nil || endpoint.IPAMConfig == nil { if endpoint == nil || endpoint.IPAMConfig == nil {
return return nil
} }
if isDefaultBridgeNetwork(netName, info) { ipam := endpoint.IPAMConfig
endpoint.IPAMConfig = nil if err := ipam.Validate(); err != nil {
return return fmt.Errorf("invalid IP configuration for network %s: %w", netName, err)
}
if ipam.IPv4Address == "" && ipam.IPv6Address == "" {
return nil
}
if netName == "host" || netName == "none" || isDefaultBridgeNetwork(netName, info) {
return fmt.Errorf("network %s does not support static IP configuration", netName)
} }
if endpoint.IPAMConfig.IPv4Address != "" && !networkSupportsStaticIP(info, endpoint.IPAMConfig.IPv4Address, false) { if ipam.IPv4Address != "" && !networkSupportsStaticIP(info, ipam.IPv4Address, false) {
endpoint.IPAMConfig.IPv4Address = "" return fmt.Errorf("static IPv4 address %s is not in a configured subnet of network %s", ipam.IPv4Address, netName)
} }
if endpoint.IPAMConfig.IPv6Address != "" && !networkSupportsStaticIP(info, endpoint.IPAMConfig.IPv6Address, true) { if ipam.IPv6Address != "" && !networkSupportsStaticIP(info, ipam.IPv6Address, true) {
endpoint.IPAMConfig.IPv6Address = "" return fmt.Errorf("static IPv6 address %s is not in a configured subnet of network %s", ipam.IPv6Address, netName)
}
if endpoint.IPAMConfig.IPv4Address == "" && endpoint.IPAMConfig.IPv6Address == "" {
endpoint.IPAMConfig = nil
} }
return nil
} }
func isDefaultBridgeNetwork(netName string, info network.Inspect) bool { func isDefaultBridgeNetwork(netName string, info network.Inspect) bool {
@@ -656,6 +684,7 @@ func networkSupportsStaticIP(info network.Inspect, ip string, isIPv6 bool) bool
if err != nil { if err != nil {
return false return false
} }
addr = addr.Unmap()
if addr.Is6() != isIPv6 { if addr.Is6() != isIPv6 {
return false return false
} }
@@ -776,16 +805,25 @@ func buildContainerRecoverNetworkConfig(networkSettings *container.NetworkSettin
if name == "host" || name == "none" { if name == "host" || name == "none" {
continue continue
} }
endpointSetting := &network.EndpointSettings{Aliases: append([]string(nil), endpoint.Aliases...), MacAddress: endpoint.MacAddress} if endpoint == nil {
if name == primaryName {
config.EndpointsConfig[name] = &network.EndpointSettings{}
} else {
extraNetworks[name] = &network.EndpointSettings{}
}
continue
}
endpointSetting := &network.EndpointSettings{
Links: append([]string(nil), endpoint.Links...),
Aliases: append([]string(nil), endpoint.Aliases...),
DriverOpts: cloneStringMap(endpoint.DriverOpts),
GwPriority: endpoint.GwPriority,
}
if endpoint.IPAMConfig != nil { if endpoint.IPAMConfig != nil {
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{ endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: endpoint.IPAMConfig.IPv4Address, IPv4Address: endpoint.IPAMConfig.IPv4Address,
IPv6Address: endpoint.IPAMConfig.IPv6Address, IPv6Address: endpoint.IPAMConfig.IPv6Address,
} LinkLocalIPs: append([]string(nil), endpoint.IPAMConfig.LinkLocalIPs...),
} else if endpoint.IPAddress != "" || endpoint.GlobalIPv6Address != "" {
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: endpoint.IPAddress,
IPv6Address: endpoint.GlobalIPv6Address,
} }
} }
if name == primaryName { if name == primaryName {

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