Compare commits

...
129 Commits
Author SHA1 Message Date
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
499 changed files with 64060 additions and 15677 deletions
+23 -37
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>
<h3 align="center">The open-source VPS control panel with native AI agent support</h3>
<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 align="center">
@@ -12,7 +9,6 @@
<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://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://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>
@@ -41,34 +37,28 @@
## 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.
👉 Watch the [2-minute introduction](https://www.youtube.com/watch?v=Jl_wqp-XA08)
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.
- **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?
| | 1Panel | cPanel / Plesk | aaPanel | Webmin |
|--|--------|----------------|---------|--------|
| Free & open source | ✅ | ❌ | Partial | ✅ |
| Native AI agent runtime | ✅ | ❌ | ❌ | ❌ |
| AI management | ✅ | ❌ | ❌ | ❌ |
| One-click app marketplace | ✅ 165+ apps | ❌ | ✅ | ❌ |
| Modern UI (post-2020) | ✅ | ❌ | Partial | ❌ |
| Docker / container management | ✅ | ❌ | ❌ | ❌ |
| 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
> **Requirements:** Linux VPS (Debian / Ubuntu / CentOS / Rocky), 1 GB RAM, internet access.
> Takes ~60 seconds.
Prepare your Linux server and run the following script:
```bash
bash -c "$(curl -sSL https://resource.1panel.pro/v2/quick_start.sh)"
@@ -83,24 +73,20 @@ Run `1pctl user-info` via SSH if you need to retrieve your access credentials.
## 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 |
|---------|:---:|:---:|
| One-click app installs | ✅ | ✅ |
| AI agents (OpenClaw) | 1 agent | Unlimited |
| WAF & advanced security | Basic | ✅ |
| Website tamper protection | ❌ | ✅ |
| Website uptime monitoring | ❌ | ✅ |
| Multi-node management | ❌ | ✅ |
| Custom logo & theme | ❌ | ✅ |
| Priority support | ❌ | ✅ |
**From $80/year.** [Compare plans & start 30-day free trial →](https://1panel.pro/pricing)
## 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)
| Feature | OSS | Pro | Ent |
|---------|:---:|:---:|:---:|
| One-click app installs | ✅ | ✅ | ✅ |
| AI agents (OpenClaw) | 5 agent | Unlimited | ✅ |
| WAF & advanced security | Basic | ✅ | ✅ |
| Website tamper protection | ❌ | ✅ | ✅ |
| Website uptime monitoring | ❌ | ✅ | ✅ |
| Multi-node management | ❌ | ✅ | ✅ |
| Custom logo & theme | ❌ | ✅ | ✅ |
| KVM Web UI | ❌ | ❌ | ✅ |
| AI Gateway | ❌ | ❌ | ✅ |
| Priority support | ❌ | ❌ | ✅ |
## Community & Support
+40
View File
@@ -2,11 +2,14 @@ package v2
import (
"errors"
"net/http"
"net/url"
"strings"
"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/repo"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/gin-gonic/gin"
)
@@ -294,6 +297,34 @@ func (b *BaseApi) UpdateAlertConfig(c *gin.Context) {
return
}
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)
return
}
@@ -346,6 +377,15 @@ func (b *BaseApi) TestAlertConfig(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
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)
if err != nil {
helper.InternalServer(c, err)
+1 -1
View File
@@ -439,7 +439,7 @@ func (b *BaseApi) ContainerItemStats(c *gin.Context) {
return
}
data, err := containerService.ContainerItemStats(req)
data, err := containerService.ContainerItemStats(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
+2 -1
View File
@@ -42,8 +42,9 @@ var (
fileShareService = service.NewIFileShareService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
firewallSettingService = service.NewIFirewallSettingService()
forwardingService = service.NewIForwardingService()
iptablesService = service.NewIIptablesService()
dockerPortGuardService = service.NewIDockerPortGuardService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
runtimeDiagnosticsService = service.NewIRuntimeDiagnosticsService()
+470 -23
View File
@@ -35,6 +35,81 @@ var cancelledChunkUploads = struct {
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
// @Summary List files
// @Accept json
@@ -474,11 +549,7 @@ func (b *BaseApi) UploadFiles(c *gin.Context) {
continue
}
dstInfo, statErr := os.Stat(dstFilename)
if overwrite {
_ = os.Remove(dstFilename)
}
err = os.Rename(tmpFilename, dstFilename)
err = finalizeUploadedFile(tmpFilename, dstFilename, overwrite)
if err != nil {
_ = os.Remove(tmpFilename)
e := fmt.Errorf("upload [%s] file failed, err: %v", file.Filename, err)
@@ -613,10 +684,35 @@ func (b *BaseApi) StopWget(c *gin.Context) {
return
}
files.CancelDownload(req.Key)
if err := files.CancelDownload(req.Key); err != nil {
helper.InternalServer(c, err)
return
}
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
// @Summary Move file
// @Accept json
@@ -638,6 +734,26 @@ func (b *BaseApi) MoveFile(c *gin.Context) {
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
// @Summary Download file
// @Accept json
@@ -792,6 +908,289 @@ func (b *BaseApi) DepthDirSize(c *gin.Context) {
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 {
defer func() {
_ = os.RemoveAll(fileDir)
@@ -871,6 +1270,10 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
helper.BadRequest(c, err)
return
}
if chunkCount <= 0 || chunkIndex < 0 || chunkIndex >= chunkCount {
helper.BadRequest(c, errors.New("invalid chunk index"))
return
}
fileOp := files.NewFileOp()
tmpDir := path.Join(global.Dir.TmpDir, "upload")
if !fileOp.Stat(tmpDir) {
@@ -885,20 +1288,25 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
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, errors.New("upload cancelled"))
helper.BadRequest(c, errChunkUploadCancelled)
return
}
if chunkIndex == 0 {
if !resumable && chunkIndex == 0 {
if fileOp.Stat(fileDir) {
_ = fileOp.DeleteDir(fileDir)
}
@@ -907,32 +1315,67 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
filePath := filepath.Join(fileDir, filename)
defer func() {
if err != nil {
if !resumable && err != nil {
_ = os.RemoveAll(fileDir)
}
}()
var (
emptyFile *os.File
chunkData []byte
)
emptyFile, err = os.Create(filePath)
if err != nil {
helper.BadRequest(c, err)
return
}
defer emptyFile.Close()
chunkData, err = io.ReadAll(uploadFile)
chunkData, err := io.ReadAll(uploadFile)
if err != nil {
helper.InternalServer(c, buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err))
return
}
if cancellable && chunkUploadCancelled(uploadID) {
err = errors.New("upload cancelled")
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)
if err != nil {
helper.BadRequest(c, err)
return
}
defer emptyFile.Close()
chunkPath := filepath.Join(fileDir, fmt.Sprintf("%s.%d", filename, chunkIndex))
err = os.WriteFile(chunkPath, chunkData, constant.DirPerm)
@@ -985,6 +1428,10 @@ func (b *BaseApi) StopChunkUpload(c *gin.Context) {
helper.InternalServer(c, err)
return
}
if err := removeActiveResumableUploadPart(uploadID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+615 -253
View File
@@ -1,34 +1,53 @@
package v2
import (
"errors"
"net/http"
"strings"
"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/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"
)
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
// @Summary Load firewall base info
// @Accept json
// @Param request body dto.OperationWithName true "request"
// @Success 200 {object} dto.FirewallBaseInfo
// @Success 200 {object} dto.FirewallSubsystemStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/base [post]
func (b *BaseApi) LoadFirewallBaseInfo(c *gin.Context) {
var req dto.OperationWithName
if err := helper.CheckBindAndValidate(&req, c); err != nil {
var request dto.OperationWithName
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
var (
data dto.FirewallBaseInfo
err error
)
if req.Name == "forward" {
data, err = forwardingService.LoadBaseInfo()
} else {
data, err = firewallService.LoadBaseInfo(req.Name)
}
data, err := firewallService.LoadBaseInfo(request.Name)
if err != nil {
helper.InternalServer(c, err)
return
@@ -37,331 +56,674 @@ func (b *BaseApi) LoadFirewallBaseInfo(c *gin.Context) {
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
}
var (
total int64
list interface{}
err error
)
if req.Type == "forward" {
total, list, err = forwardingService.SearchWithPage(dto.ForwardRuleSearch{
PageInfo: req.PageInfo,
Info: req.Info,
Status: req.Status,
Strategy: req.Strategy,
})
} else {
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
// @Summary Operate firewall
// @Accept json
// @Param request body dto.FirewallOperation true "request"
// @Success 200
// @Param request body dto.FirewallLifecycleOperation true "request"
// @Success 200 {object} dto.FirewallLifecycleOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/operate [post]
// @x-panel-log {"bodyKeys":["operation"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operation] 防火墙","formatEN":"[operation] firewall"}
func (b *BaseApi) OperateFirewall(c *gin.Context) {
var req dto.FirewallOperation
if err := helper.CheckBindAndValidate(&req, c); err != nil {
var request dto.FirewallLifecycleOperation
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if err := firewallService.OperateFirewall(req); err != nil {
result, err := firewallService.QueueFirewallOperation(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Create group
// @Summary Load forwarding base info
// @Accept json
// @Param request body dto.PortRuleOperate true "request"
// @Success 200
// @Success 200 {object} dto.FirewallSubsystemStatus
// @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 {
// @Router /hosts/firewall/forward/base [post]
func (b *BaseApi) LoadForwardingBaseInfo(c *gin.Context) {
data, err := forwardingService.LoadBaseInfo()
if 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 := forwardingService.Operate(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
helper.SuccessWithData(c, data)
}
// @Tags Firewall
// @Summary Operate Ip rule
// @Summary Page forwarding rules
// @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"
// @Param request body dto.ForwardRuleSearch 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 {
// @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, list, err := iptablesService.Search(req)
total, items, err := forwardingService.SearchRules(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Items: list,
Total: total,
})
helper.SuccessWithData(c, dto.PageResult{Items: items, Total: total})
}
// @Tags Firewall
// @Summary Operate iptables filter rule
// @Summary Operate forwarding rules
// @Accept json
// @Param request body dto.IptablesRuleOp true "request"
// @Success 200
// @Param request body dto.ForwardRuleOperate true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/filter/rule/operate [post]
// @x-panel-log {"bodyKeys":["operation","chain"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operation] filter规则到 [chain]","formatEN":"[operation] filter rule to [chain]"}
func (b *BaseApi) OperateFilterRule(c *gin.Context) {
var req dto.IptablesRuleOp
if err := helper.CheckBindAndValidate(&req, c); err != nil {
// @Router /hosts/firewall/forward/operate [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新端口转发规则","formatEN":"update port forward rules"}
func (b *BaseApi) OperateForwardingRules(c *gin.Context) {
var request dto.ForwardRuleOperate
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if err := iptablesService.OperateRule(req, true); err != nil {
result, err := forwardingService.OperateRules(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Batch operate iptables filter rules
// @Summary Enable forwarding
// @Accept json
// @Param request body dto.IptablesBatchOperate true "request"
// @Success 200
// @Param request body dto.FirewallInitializationTask true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/filter/rule/batch [post]
func (b *BaseApi) BatchOperateFilterRule(c *gin.Context) {
var req dto.IptablesBatchOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
// @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
}
if err := iptablesService.BatchOperate(req); err != nil {
result, err := forwardingService.QueueInitialization(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Apply/Unload/Init iptables filter
// @Summary Apply/Unload/Init firewall filter chain
// @Accept json
// @Param request body dto.IptablesOp true "request"
// @Success 200
// @Param request body dto.FilterChainOperation true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @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) {
var req dto.IptablesOp
if err := helper.CheckBindAndValidate(&req, c); err != nil {
var request dto.FilterChainOperation
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
var err error
if req.Operate == "init-forward" {
err = forwardingService.Enable()
} else {
err = iptablesService.Operate(req)
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 != nil {
if err := firewallService.OperateFilterChain(request); err != nil {
helper.InternalServer(c, err)
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)
}
// @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
// @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
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/filter/chain/status [post]
func (b *BaseApi) LoadChainStatus(c *gin.Context) {
var req dto.OperationWithName
if err := helper.CheckBindAndValidate(&req, c); err != nil {
// @Router /hosts/firewall/settings/whitelist [post]
// @x-panel-log {"bodyKeys":["rule"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建防火墙端口白名单","formatEN":"create firewall port whitelist"}
func (b *BaseApi) CreateFirewallPortWhitelist(c *gin.Context) {
var request dto.FirewallPortWhitelistCreate
if err := helper.CheckBindAndValidate(&request, c); err != nil {
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 (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/gpu"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/gpu/common"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/xpu"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/gin-gonic/gin"
)
@@ -17,27 +16,20 @@ import (
// @Security Timestamp
// @Router /ai/gpu/load [get]
func (b *BaseApi) LoadGpuInfo(c *gin.Context) {
ok, client := gpu.New()
ok, client := accelerator.New()
if ok {
info, err := client.LoadGpuInfo()
snapshot, err := client.Collect(c.Request.Context())
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
xpuOK, xpuClient := xpu.New()
if xpuOK {
info, err := xpuClient.LoadGpuInfo()
if err != nil {
helper.BadRequest(c, err)
return
if warning := snapshot.Warning(); warning != nil {
global.LOG.Warnf("load realtime accelerator data partially failed, err: %v", warning)
}
helper.SuccessWithData(c, info)
helper.SuccessWithData(c, &snapshot.Info)
return
}
helper.SuccessWithData(c, &common.GpuInfo{})
helper.SuccessWithData(c, &accelerator.Info{})
}
// @Tags AI
+20
View File
@@ -30,6 +30,26 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
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) {
ErrorWithDetail(ctx, http.StatusInternalServerError, "ErrInternalServer", err)
}
+143 -31
View File
@@ -1,11 +1,14 @@
package v2
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
@@ -19,29 +22,35 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
gossh "golang.org/x/crypto/ssh"
)
// @Tags Terminal
// @Summary Ws local terminal
// @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
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/terminal/local [get]
func (b *BaseApi) WsLocalTerminal(c *gin.Context) {
b.runSSHSession(c, loadLocalConn, c.DefaultQuery("command", ""))
b.runSSHSession(c, "local", loadLocalConn, c.DefaultQuery("command", ""))
}
// @Tags Terminal
// @Summary Ws host SSH
// @Param id query integer false "id"
// @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
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/terminal/ssh [get]
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"))
if hostID <= 0 {
return nil, errors.New("missing host id")
@@ -65,26 +74,33 @@ func (b *BaseApi) WsContainerTerminal(c *gin.Context) {
return
}
defer wsConn.Close()
slave, err := loadContainerTerminalCommand(c)
if wshandleError(wsConn, err) {
return
}
defer slave.Close()
tty, err := terminal.NewLocalWsSession(cols, rows, wsConn, slave, false)
if wshandleError(wsConn, err) {
identity, ok := loadTerminalIdentity(c)
if !ok {
_ = wshandleError(wsConn, errors.New("missing terminal identity"))
return
}
quitChan := make(chan bool, 3)
tty.Start(quitChan)
go slave.Wait(quitChan)
opts := terminal.SessionOptions{
Identity: identity,
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
global.LOG.Info("websocket finished")
closeTerminalConn(wsConn)
func containerTerminalTarget(c *gin.Context) string {
query := c.Request.URL.Query()
for _, key := range []string{"cols", "rows", "session", "terminalRevalidate"} {
query.Del(key)
}
sum := sha256.Sum256([]byte(query.Encode()))
return hex.EncodeToString(sum[:])
}
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
}
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)
if !ok {
return
}
defer wsConn.Close()
client, clientErr := connect()
if wshandleError(wsConn, errors.WithMessage(clientErr, "failed to set up the connection. Please check the host information")) {
identity, ok := loadTerminalIdentity(c)
if !ok {
_ = wshandleError(wsConn, errors.New("missing terminal identity"))
return
}
defer client.Close()
sws, err := terminal.NewLogicSshWsSession(cols, rows, client.Client, wsConn, command)
if wshandleError(wsConn, err) {
hostID := 0
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
}
defer sws.Close()
helper.SuccessWithData(c, terminal.List(identity))
}
quitChan := make(chan bool, 3)
sws.Start(quitChan)
go sws.Wait(quitChan)
// @Tags Terminal
// @Summary Close a terminal session
// @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) {
+16 -7
View File
@@ -162,16 +162,25 @@ type AgentWebsiteBindReq struct {
}
type AgentModelConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
AccountID uint `json:"accountId" validate:"required"`
Model string `json:"model" validate:"required"`
Fallbacks []string `json:"fallbacks"`
AgentID uint `json:"agentId" validate:"required"`
AccountID uint `json:"accountId" validate:"required"`
Model string `json:"model" validate:"required"`
Fallbacks []string `json:"fallbacks"`
Metadata []AgentModelMetadata `json:"metadata" validate:"dive"`
}
type AgentModelConfig struct {
AccountID uint `json:"accountId"`
Model string `json:"model"`
Fallbacks []string `json:"fallbacks"`
AccountID uint `json:"accountId"`
Model string `json:"model"`
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 {
+29 -16
View File
@@ -151,16 +151,25 @@ type AlertLog struct {
}
type AlertDetail struct {
LicenseId string `json:"licenseId"`
Type string `json:"type"`
SubType string `json:"subType"`
Title string `json:"title"`
Method string `json:"method"`
LicenseCode string `json:"licenseCode"`
DeviceId string `json:"deviceId"`
Project string `json:"project"`
Params []Param `json:"params"`
Phone string `json:"phone"`
LicenseId string `json:"licenseId"`
Type string `json:"type"`
SubType string `json:"subType"`
Title string `json:"title"`
Method string `json:"method"`
LicenseCode string `json:"licenseCode"`
DeviceId string `json:"deviceId"`
Project string `json:"project"`
Params []Param `json:"params"`
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 {
@@ -295,15 +304,19 @@ type OfflineQueryRequest struct {
}
type AlertConfigUpdate struct {
ID uint `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Status string `json:"status"`
Config string `json:"config"`
DisplayName string `json:"displayName"`
ID uint `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Status string `json:"status"`
Config string `json:"config"`
DisplayName string `json:"displayName"`
Revision *time.Time `json:"revision"`
}
type AlertConfigTest struct {
ID uint `json:"id"`
Type string `json:"type"`
Config string `json:"config"`
Host string `json:"host"`
Port int `json:"port"`
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"`
}
+4 -3
View File
@@ -6,9 +6,10 @@ type PageResult struct {
}
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
Code int `json:"code"`
ErrorCode string `json:"errorCode,omitempty"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
type Options struct {
+35 -7
View File
@@ -121,6 +121,7 @@ type DashboardCurrent struct {
NetBytesRecv uint64 `json:"netBytesRecv"`
GPUData []GPUInfo `json:"gpuData"`
NPUData []NPUInfo `json:"npuData"`
XPUData []XPUInfo `json:"xpuData"`
TopCPUItems []Process `json:"topCPUItems"`
@@ -156,8 +157,12 @@ type DiskInfo struct {
}
type GPUInfo 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"`
GPUUtil string `json:"gpuUtil"`
Temperature string `json:"temperature"`
PerformanceState string `json:"performanceState"`
@@ -170,6 +175,27 @@ type GPUInfo struct {
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 {
Key string `json:"key"`
Type string `json:"type"`
@@ -202,11 +228,13 @@ type LauncherOption struct {
}
type XPUInfo struct {
DeviceID int `json:"deviceID"`
DeviceName string `json:"deviceName"`
Memory string `json:"memory"`
Temperature string `json:"temperature"`
MemoryUsed string `json:"memoryUsed"`
Power string `json:"power"`
MemoryUtil string `json:"memoryUtil"`
DeviceID int `json:"deviceID"`
DeviceName string `json:"deviceName"`
PciBdfAddress string `json:"pciBdfAddress"`
Memory string `json:"memory"`
Temperature string `json:"temperature"`
GPUUtil string `json:"gpuUtil"`
MemoryUsed string `json:"memoryUsed"`
Power string `json:"power"`
MemoryUtil string `json:"memoryUtil"`
}
+358 -73
View File
@@ -1,100 +1,385 @@
package dto
type FirewallBaseInfo struct {
Name string `json:"name"`
IsExist bool `json:"isExist"`
IsActive bool `json:"isActive"`
IsInit bool `json:"isInit"`
IsBind bool `json:"isBind"`
Version string `json:"version"`
PingStatus string `json:"pingStatus"`
import (
"github.com/1Panel-dev/1Panel/agent/utils/firewall"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
firewallsync "github.com/1Panel-dev/1Panel/agent/utils/firewall/sync"
)
type FirewallSubsystemStatus struct {
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 {
PageInfo
Info string `json:"info"`
Status string `json:"status"`
Strategy string `json:"strategy"`
Type string `json:"type" validate:"required"`
}
type FirewallOperation struct {
type FirewallLifecycleOperation struct {
Operation string `json:"operation" validate:"required,oneof=start stop restart disableBanPing enableBanPing"`
WithDockerRestart bool `json:"withDockerRestart"`
}
type PortRuleOperate struct {
ID uint `json:"id"`
Operation string `json:"operation" validate:"required,oneof=add remove"`
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 FirewallLifecycleOperationResponse struct {
TaskID string `json:"taskID,omitempty"`
Queued bool `json:"queued"`
}
type UpdateFirewallDescription struct {
Type string `json:"type"`
Chain string `json:"chain"`
SrcIP string `json:"srcIP"`
DstIP string `json:"dstIP"`
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 FirewallBackendOption struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Active bool `json:"active"`
Initialized bool `json:"initialized"`
Bound bool `json:"bound"`
Supported bool `json:"supported"`
SupportReason string `json:"supportReason,omitempty"`
Implementation string `json:"implementation,omitempty"`
Message string `json:"message,omitempty"`
IPv4 FirewallBackendFamilyStatus `json:"ipv4"`
IPv6 FirewallBackendFamilyStatus `json:"ipv6"`
}
type AddrRuleOperate struct {
ID uint `json:"id"`
Operation string `json:"operation" validate:"required,oneof=add remove"`
Address string `json:"address" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=accept drop"`
Description string `json:"description"`
type FirewallBackendFamilyStatus struct {
Available bool `json:"available"`
Initialized bool `json:"initialized"`
Bound bool `json:"bound"`
Reason string `json:"reason,omitempty"`
}
type PortRuleUpdate struct {
OldRule PortRuleOperate `json:"oldRule"`
NewRule PortRuleOperate `json:"newRule"`
type FirewallBackendGroup struct {
Selected string `json:"selected"`
Current string `json:"current,omitempty"`
Options []FirewallBackendOption `json:"options"`
}
type AddrRuleUpdate struct {
OldRule AddrRuleOperate `json:"oldRule"`
NewRule AddrRuleOperate `json:"newRule"`
type FirewallSettings struct {
System FirewallBackendGroup `json:"system"`
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 BatchRuleOperate struct {
Type string `json:"type" validate:"required"`
Rules []PortRuleOperate `json:"rules"`
type FirewallPortWhitelistCreate struct {
Rule filter.PortWhitelist `json:"rule" validate:"required"`
}
type IptablesOp struct {
Name string `json:"name" validate:"required,oneof=1PANEL_INPUT 1PANEL_OUTPUT 1PANEL_BASIC 1PANEL_FORWARD"`
Operate string `json:"operate" validate:"required,oneof=init-base init-forward init-advance bind-base unbind-base bind unbind"`
type FirewallPortWhitelistUpdate struct {
OldRule filter.PortWhitelist `json:"oldRule" validate:"required"`
Rule filter.PortWhitelist `json:"rule" validate:"required"`
}
type IptablesRuleOp struct {
Operation string `json:"operation" validate:"required,oneof=add remove"`
ID uint `json:"id"`
Chain string `json:"chain" validate:"required,oneof=1PANEL_BASIC 1PANEL_BASIC_BEFORE 1PANEL_INPUT 1PANEL_OUTPUT"`
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 FirewallPortWhitelistDelete struct {
Rule *filter.PortWhitelist `json:"rule" validate:"required"`
}
type IptablesBatchOperate struct {
Rules []IptablesRuleOp `json:"rules"`
type FirewallBackendOperation struct {
Subsystem string `json:"subsystem" validate:"required,oneof=system forwarding docker"`
Backend string `json:"backend" validate:"required,oneof=firewalld ufw iptables nftables"`
Operation string `json:"operation" validate:"required,oneof=select initialize cleanup"`
}
type IptablesChainStatus struct {
IsBind bool `json:"isBind"`
DefaultStrategy string `json:"defaultStrategy"`
type FilterChainOperation struct {
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 FilterChainOperationResponse struct {
TaskID string `json:"taskID"`
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++
}
}
}
+7 -3
View File
@@ -2,13 +2,12 @@ package dto
type ForwardRuleSearch struct {
PageInfo
All bool `json:"all,omitempty"`
Info string `json:"info"`
Status string `json:"status"`
Strategy string `json:"strategy"`
}
// ForwardRule preserves the existing firewall search response shape while
// keeping forwarding data separate from the filter client model.
type ForwardRule struct {
ID uint `json:"id"`
Chain string `json:"chain"`
@@ -25,16 +24,21 @@ type ForwardRule struct {
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"`
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"`
+2
View File
@@ -45,10 +45,12 @@ type MonitorGPUOptions struct {
}
type GPUChartHide struct {
ProductName string `json:"productName"`
Type string `json:"type"`
Process bool `json:"process"`
GPU bool `json:"gpu"`
Memory bool `json:"memory"`
Power bool `json:"power"`
PowerLimit bool `json:"powerLimit"`
Temperature bool `json:"temperature"`
Speed bool `json:"speed"`
}
+7 -1
View File
@@ -51,13 +51,19 @@ const (
CACHE NginxKey = "cache"
HttpPer NginxKey = "http-per"
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{
Index: {"index"},
LimitConn: {"limit_conn", "limit_rate", "limit_conn_zone"},
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 {
+9
View File
@@ -50,6 +50,10 @@ type AppContainerConfig struct {
Type string `json:"type"`
SpecifyIP string `json:"specifyIP"`
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 {
@@ -92,6 +96,8 @@ type AppInstalledOperate struct {
TaskID string `json:"taskID"`
DeleteImage bool `json:"deleteImage"`
Favorite bool `json:"favorite"`
UseLifecycleScripts bool `json:"-"`
}
type AppInstallUpgrade struct {
@@ -111,11 +117,14 @@ type AppInstallDelete struct {
DeleteDB bool `json:"deleteDB"`
DeleteImage bool `json:"deleteImage"`
TaskID string `json:"taskID"`
UseLifecycleScripts bool `json:"-"`
}
type AppInstalledUpdate struct {
InstallId uint `json:"installId" validate:"required"`
Params map[string]interface{} `json:"params" validate:"required"`
TaskID string `json:"-"`
AppContainerConfig
}
+10
View File
@@ -122,6 +122,7 @@ type FileWget struct {
Name string `json:"name" validate:"required"`
IgnoreCertificate bool `json:"ignoreCertificate"`
UseProxy bool `json:"useProxy"`
UseServerFilename bool `json:"useServerFilename"`
}
type FileMove struct {
@@ -131,6 +132,11 @@ type FileMove struct {
Name string `json:"name"`
Cover bool `json:"cover"`
CoverPaths []string `json:"coverPaths"`
TaskID string `json:"taskID"`
}
type FileMoveStopReq struct {
TaskID string `json:"taskID" validate:"required"`
}
type FileDownload struct {
@@ -153,6 +159,10 @@ type FileProcessReq struct {
Key string `json:"key"`
}
type FileProcessRemoveReq struct {
Keys []string `json:"keys" validate:"required,min=1,max=1000"`
}
type FileRoleUpdate struct {
Path string `json:"path" validate:"required"`
User string `json:"user" validate:"required"`
+9 -8
View File
@@ -66,14 +66,15 @@ type RuntimeDelete struct {
}
type RuntimeUpdate struct {
Name string `json:"name"`
ID uint `json:"id"`
Image string `json:"image"`
Version string `json:"version"`
Rebuild bool `json:"rebuild"`
Source string `json:"source"`
CodeDir string `json:"codeDir"`
Remark string `json:"remark"`
AppDetailID uint `json:"appDetailId"`
Name string `json:"name"`
ID uint `json:"id"`
Image string `json:"image"`
Version string `json:"version"`
Rebuild bool `json:"rebuild"`
Source string `json:"source"`
CodeDir string `json:"codeDir"`
Remark string `json:"remark"`
Params map[string]interface{} `json:"params"`
NodeConfig
+11
View File
@@ -17,6 +17,17 @@ type NginxParam struct {
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 {
Enable bool `json:"enable"`
Items []dto.NginxAuth `json:"items"`
+1 -1
View File
@@ -35,7 +35,7 @@ type SettingUpdate 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"`
}
+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"`
}
+27 -10
View File
@@ -1,5 +1,12 @@
package model
import (
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Alert struct {
BaseModel
@@ -18,10 +25,11 @@ type Alert struct {
type AlertTask struct {
BaseModel
Type string `gorm:"type:varchar(64);not null" json:"type"`
Quota string `gorm:"type:varchar(64)" json:"quota"`
QuotaType string `gorm:"type:varchar(64)" json:"quotaType"`
Method string `gorm:"type:varchar(128);not null;default:'sms'" json:"method"`
Type string `gorm:"type:varchar(64);not null" json:"type"`
Quota string `gorm:"type:varchar(64)" json:"quota"`
QuotaType string `gorm:"type:varchar(64)" json:"quotaType"`
Method string `gorm:"type:varchar(128);not null;default:'sms'" json:"method"`
DeliveryLogID *uint `gorm:"uniqueIndex" json:"-"`
}
type AlertLog struct {
@@ -41,12 +49,21 @@ type AlertLog struct {
type AlertConfig struct {
BaseModel
Type string `gorm:"type:varchar(64);not null" json:"type"`
Title string `gorm:"type:varchar(64);not null" json:"title"`
Status string `gorm:"type:varchar(64);not null" json:"status"`
Config string `gorm:"type:varchar(256);not null" json:"config"`
CreateUser string `gorm:"type:varchar(256)" json:"createUser"`
UpdateUser string `gorm:"type:varchar(256)" json:"updateUser"`
UID string `gorm:"type:varchar(64);not null;uniqueIndex" json:"uid"`
Type string `gorm:"type:varchar(64);not null" json:"type"`
Title string `gorm:"type:varchar(64);not null" json:"title"`
Status string `gorm:"type:varchar(64);not null" json:"status"`
Config string `gorm:"type:text;not null" json:"config"`
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 {
+217 -13
View File
@@ -1,18 +1,222 @@
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
Type string `json:"type"`
Port string `json:"port"` // Deprecated
Address string `json:"address"` // Deprecated
Chain string `json:"chain"`
Protocol string `json:"protocol"`
SrcIP string `json:"srcIP"`
SrcPort string `json:"srcPort"`
DstIP string `json:"dstIP"`
DstPort string `json:"dstPort"`
Strategy string `gorm:"not null" json:"strategy"`
Description string `json:"description"`
UUID string `gorm:"size:64;not null;uniqueIndex" json:"uuid"`
ReadOnly bool `gorm:"not null;default:false;uniqueIndex:idx_docker_port_guard_endpoint" json:"-"`
Family string `gorm:"size:16;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"family"`
HostIP string `gorm:"size:64;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostIP"`
HostPort uint16 `gorm:"not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostPort"`
Protocol string `gorm:"size:8;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"protocol"`
Mode string `gorm:"size:32;not null" json:"mode"`
Sources string `gorm:"type:text" json:"-"`
Description string `gorm:"type:text" json:"description"`
NativeAction string `gorm:"size:32;not null;default:''" json:"-"`
NativeRules string `gorm:"type:text" json:"-"`
Sequence int64 `gorm:"not null;default:0" json:"-"`
}
type ForwardingRule struct {
BaseModel
Family string `gorm:"size:16;not null;uniqueIndex:idx_forwarding_rule_identity" json:"family"`
Protocol string `gorm:"size:8;not null;uniqueIndex:idx_forwarding_rule_identity" json:"protocol"`
Port string `gorm:"size:32;not null;uniqueIndex:idx_forwarding_rule_identity" json:"port"`
TargetIP string `gorm:"size:64;not null;uniqueIndex:idx_forwarding_rule_identity" json:"targetIP"`
TargetPort string `gorm:"size:32;not null;uniqueIndex:idx_forwarding_rule_identity" json:"targetPort"`
Interface string `gorm:"size:32;not null;default:'';uniqueIndex:idx_forwarding_rule_identity" json:"interface"`
}
type FirewallRule struct {
UUID string `gorm:"size:64;primaryKey" json:"uuid"`
Family string `gorm:"size:16;not null" json:"family"`
Protocol string `gorm:"size:32;not null" json:"protocol"`
SourceAddress string `gorm:"size:255" json:"sourceAddress"`
SourcePort string `gorm:"size:64" json:"sourcePort"`
DestinationAddress string `gorm:"size:255" json:"destinationAddress"`
DestinationPort string `gorm:"size:64" json:"destinationPort"`
Interface string `gorm:"size:128" json:"interface"`
ConnectionStates string `gorm:"type:text" json:"connectionStates"`
Action string `gorm:"size:32;not null" json:"action"`
Description string `gorm:"type:text" json:"description"`
CompatibilityError string `gorm:"type:text" json:"compatibilityError,omitempty"`
Priority *int `json:"priority,omitempty"`
Sequence *int64 `gorm:"index" json:"sequence,omitempty"`
Origin string `gorm:"size:32;not null" json:"origin"`
Owner string `gorm:"size:320;not null" json:"owner"`
Revision uint `gorm:"not null;default:1" json:"revision"`
}
func FirewallRuleOwner(sourceKind, sourceID string) string {
sourceKind = strings.TrimSpace(sourceKind)
sourceID = strings.TrimSpace(sourceID)
if sourceID == "" {
return sourceKind
}
return sourceKind + ":" + sourceID
}
func FirewallRuleFromDomain(rule filter.FirewallRule) (FirewallRule, error) {
normalized, err := filter.NormalizeRule(rule)
if err != nil {
return FirewallRule{}, err
}
switch normalized.NativeKind {
case "", filter.NativeKindRule, filter.NativeKindZonePort, filter.NativeKindRichRule, filter.NativeKindUFWRule:
default:
return FirewallRule{}, fmt.Errorf("%w: native rule %q cannot be stored as a provider-neutral policy", filter.ErrUnsupportedScope, normalized.NativeKind)
}
record := FirewallRule{
Family: string(normalized.Scope.Family),
Protocol: normalized.Protocol,
SourceAddress: normalized.SourceAddress,
SourcePort: normalized.SourcePort,
DestinationAddress: normalized.DestinationAddress,
DestinationPort: normalized.DestinationPort,
Interface: normalized.Interface,
ConnectionStates: strings.Join(normalized.ConnectionStates, ","),
Action: string(normalized.Action),
Description: normalized.Description,
}
if normalized.Scope.Provider == filter.ProviderFirewalld {
record.Priority = normalized.Priority
}
return record, nil
}
func (rule FirewallRule) PolicyKey() string {
payload, _ := json.Marshal(struct {
Family string `json:"family"`
Protocol string `json:"protocol"`
SourceAddress string `json:"sourceAddress,omitempty"`
SourcePort string `json:"sourcePort,omitempty"`
DestinationAddress string `json:"destinationAddress,omitempty"`
DestinationPort string `json:"destinationPort,omitempty"`
Interface string `json:"interface,omitempty"`
ConnectionStates string `json:"connectionStates,omitempty"`
Action string `json:"action"`
}{
Family: rule.Family, Protocol: rule.Protocol,
SourceAddress: rule.SourceAddress, SourcePort: rule.SourcePort,
DestinationAddress: rule.DestinationAddress, DestinationPort: rule.DestinationPort,
Interface: rule.Interface, ConnectionStates: rule.ConnectionStates, Action: rule.Action,
})
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
func (rule FirewallRule) RulesForProvider(provider filter.Provider) ([]filter.FirewallRule, error) {
if rule.CompatibilityError != "" {
return nil, fmt.Errorf("%w: %s", filter.ErrUnsupportedScope, rule.CompatibilityError)
}
connectionStates := make([]string, 0)
if rule.ConnectionStates != "" {
connectionStates = strings.Split(rule.ConnectionStates, ",")
}
base := filter.FirewallRule{
Protocol: rule.Protocol, SourceAddress: rule.SourceAddress, SourcePort: rule.SourcePort,
DestinationAddress: rule.DestinationAddress, DestinationPort: rule.DestinationPort,
Interface: rule.Interface, ConnectionStates: connectionStates,
Action: filter.Action(rule.Action), Description: rule.Description,
}
if provider != filter.ProviderUFW && strings.EqualFold(strings.TrimSpace(base.Protocol), "all") &&
strings.TrimSpace(base.SourcePort) == "" && strings.TrimSpace(base.DestinationPort) != "" {
base.Protocol = "tcp/udp"
}
if provider == filter.ProviderFirewalld {
base.Priority = rule.Priority
}
families := []filter.Family{filter.Family(rule.Family)}
if provider != filter.ProviderFirewalld && families[0] == filter.FamilyInet {
hasIPv4, hasIPv6 := ruleAddressFamilies(base)
switch {
case hasIPv4 && hasIPv6:
return nil, fmt.Errorf("%w: inet policy contains both IPv4 and IPv6 addresses", filter.ErrUnsupportedScope)
case hasIPv6 || strings.EqualFold(base.Protocol, "icmpv6"):
families = []filter.Family{filter.FamilyIPv6}
case hasIPv4:
families = []filter.Family{filter.FamilyIPv4}
default:
families = []filter.Family{filter.FamilyIPv4, filter.FamilyIPv6}
}
}
result := make([]filter.FirewallRule, 0, len(families))
for _, family := range families {
compiled := base
compiled.Scope = filter.Scope{Provider: provider, Family: family, Direction: filter.DirectionInput}
switch provider {
case filter.ProviderIptables, filter.ProviderNftables:
compiled.Scope.Table, compiled.Scope.Chain = "filter", filter.IptablesInputChain
case filter.ProviderFirewalld:
compiled.Scope.Zone = filter.FirewalldInputZone
case filter.ProviderUFW:
compiled.Scope.Chain = filter.UFWInputChain
default:
return nil, fmt.Errorf("%w: unsupported firewall provider %q", filter.ErrProviderUnavailable, provider)
}
expanded, err := filter.ExpandAtomicRules(compiled)
if err != nil {
return nil, err
}
result = append(result, expanded...)
}
return result, nil
}
func SortFirewallRules(rules []FirewallRule, provider filter.Provider) {
sort.SliceStable(rules, func(i, j int) bool {
left, right := rules[i], rules[j]
if provider == filter.ProviderFirewalld {
switch {
case left.Priority == nil && right.Priority != nil:
return false
case left.Priority != nil && right.Priority == nil:
return true
case left.Priority != nil && right.Priority != nil && *left.Priority != *right.Priority:
return *left.Priority < *right.Priority
}
} else {
switch {
case left.Sequence == nil && right.Sequence != nil:
return false
case left.Sequence != nil && right.Sequence == nil:
return true
case left.Sequence != nil && right.Sequence != nil && *left.Sequence != *right.Sequence:
return *left.Sequence < *right.Sequence
}
}
return left.UUID < right.UUID
})
}
func ruleAddressFamilies(rule filter.FirewallRule) (bool, bool) {
hasIPv4, hasIPv6 := false, false
for _, address := range []string{rule.SourceAddress, rule.DestinationAddress} {
address = strings.TrimSpace(address)
if address == "" {
continue
}
if strings.Contains(address, ":") {
hasIPv6 = true
} else {
hasIPv4 = true
}
}
return hasIPv4, hasIPv6
}
+26 -5
View File
@@ -40,20 +40,30 @@ type Meta struct {
var catalog = map[string]Meta{
"custom": {
Key: "custom", DisplayName: "Custom", Sort: 10, DefaultAPIType: "openai-completions", EnvKey: "CUSTOM_API_KEY",
APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"),
APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "dashscope-images", "openai-embeddings"),
},
"ollama": {
Key: "ollama", DisplayName: "Ollama", Sort: 15, DefaultAPIType: "openai-responses",
APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions"),
APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions", "openai-embeddings"),
},
// llmman (https://github.com/llmmanorg/llmman): local runner with Ollama/OpenAI-compatible routes on 127.0.0.1:17434.
"llmman": {
Key: "llmman", DisplayName: "llmman", Sort: 16, DefaultAPIType: "openai-responses",
APIConfigs: []APIConfig{
{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": {
Key: "vllm", DisplayName: "vLLM", Sort: 20, DefaultAPIType: "openai-completions", EnvKey: "VLLM_API_KEY",
APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"),
APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "openai-embeddings"),
},
"deepseek": {
Key: "deepseek", DisplayName: "DeepSeek", Sort: 25, DefaultAPIType: "openai-completions", EnvKey: "DEEPSEEK_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://api.deepseek.com"},
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
},
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
@@ -131,6 +141,10 @@ var catalog = map[string]Meta{
{APIType: "openai-responses", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-completions", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-images", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-embeddings", BaseURL: "https://api.openai.com/v1", Models: []Model{
{ID: "text-embedding-3-small", Name: "text-embedding-3-small"},
{ID: "text-embedding-3-large", Name: "text-embedding-3-large"},
}},
},
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"}},
},
@@ -296,7 +310,7 @@ func DefaultModels(key, apiType string) []Model {
if len(config.Models) > 0 {
return append([]Model(nil), config.Models...)
}
if IsImageAPIType(config.APIType) {
if IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType) {
return nil
}
break
@@ -359,7 +373,7 @@ func ResolveBaseURL(key, apiType, requested string) (string, error) {
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid base url")
}
if key == "custom" && IsImageAPIType(config.APIType) {
if key == "custom" && (IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType)) {
return baseURL, nil
}
parsed.Path = normalizeEndpointPath(config.APIType, parsed.Path)
@@ -381,6 +395,8 @@ func normalizeEndpointPath(apiType, value string) string {
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) {
@@ -390,6 +406,10 @@ func normalizeEndpointPath(apiType, value string) string {
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":
@@ -442,6 +462,7 @@ 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"},
+3 -3
View File
@@ -23,7 +23,7 @@ func BuildOpenClawProviderPatch(provider, modelName, apiType, authMode, baseURL,
if _, ok := FindAPIConfig(provider, resolvedAPIType); !ok {
resolvedAPIType = DefaultAPIType(provider)
}
if IsImageAPIType(resolvedAPIType) {
if IsImageAPIType(resolvedAPIType) || IsEmbeddingAPIType(resolvedAPIType) {
return nil, fmt.Errorf("api type %s does not support text generation", resolvedAPIType)
}
resolvedAuthMode, err := ResolveAuthMode(provider, resolvedAPIType, authMode)
@@ -43,8 +43,8 @@ func BuildOpenClawProviderPatch(provider, modelName, apiType, authMode, baseURL,
providerKey = "moonshot"
resolvedAPIType = "openai-completions"
usesBearer = false
case "ollama":
apiKey = "ollama"
case "ollama", "llmman":
apiKey = provider
usesBearer = false
case "openai", "openrouter", "anthropic":
preserveQualifiedModel = strings.Contains(modelName, "/")
+24 -6
View File
@@ -27,11 +27,14 @@ type verifyErrorResponse struct {
Message string `json:"message"`
}
const defaultVerifyTimeout = 30 * time.Second
const (
defaultVerifyTimeout = 30 * time.Second
defaultVerifyMaxTokens = 16
)
func SkipVerification(provider string) bool {
switch provider {
case "vllm", "ollama", "kimi-coding":
case "vllm", "ollama", "llmman", "kimi-coding":
return true
default:
return false
@@ -81,6 +84,10 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
}
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
@@ -112,25 +119,36 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
}
headers["anthropic-version"] = "2023-06-01"
request.Body = mustJSON(map[string]interface{}{
"model": model, "max_tokens": 1, "stream": false,
"model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false,
"messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}},
})
case "openai-responses":
request.URL = baseURL + "/responses"
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "input": "test", "max_output_tokens": 1, "stream": false})
request.Body = mustJSON(map[string]interface{}{"model": model, "input": "test", "max_output_tokens": defaultVerifyMaxTokens, "stream": false})
default:
request.URL = baseURL + "/chat/completions"
if provider != "ollama" || strings.TrimSpace(apiKey) != "" {
if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" {
headers["Authorization"] = "Bearer " + apiKey
}
request.Body = mustJSON(map[string]interface{}{
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": 1, "stream": false,
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false,
})
}
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
+1 -1
View File
@@ -86,7 +86,7 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
Model(&model.AgentAccount{}).
Select("provider, COUNT(*) as count").
Where("provider IN ?", normalizedProviders).
Where("api_type NOT LIKE ?", "%-images").
Scopes(WithTextAPIType()).
Group("provider").
Scan(&rows).Error; err != nil {
return nil, err
+217 -9
View File
@@ -1,20 +1,30 @@
package repo
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"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"
"google.golang.org/genproto/googleapis/type/date"
"gorm.io/gorm"
"strconv"
"time"
"gorm.io/gorm/clause"
)
type AlertRepo struct{}
var (
ErrAlertConfigRevisionConflict = errors.New("alert config revision conflict")
ErrAlertConfigRevisionRequired = errors.New("alert config revision is required")
)
type IAlertRepo interface {
WithByType(alertType string) DBOption
WithByStatusIn(status []string) DBOption
@@ -24,6 +34,7 @@ type IAlertRepo interface {
WithByCreateAt(date *date.Date) DBOption
WithByLicenseId(licenseId string) DBOption
WithByRecordId(recordId uint) DBOption
WithByDeliveryLogID(logID uint) DBOption
WithByAlertMethodContainsConfigID(id uint) DBOption
WithByMethodConfigIDs(ids []uint) DBOption
@@ -45,6 +56,8 @@ type IAlertRepo interface {
CleanAlertLogs() 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
GetAlertTask(opts ...DBOption) (model.AlertTask, error)
LoadTaskCount(alertType string, project string, method string) (uint, uint, error)
@@ -55,6 +68,7 @@ type IAlertRepo interface {
GetConfigById(id uint) (model.AlertConfig, error)
AlertConfigList(opts ...DBOption) ([]model.AlertConfig, 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
DeleteAlertConfig(opts ...DBOption) error
@@ -223,13 +237,78 @@ func (a *AlertRepo) DeleteLog(opts ...DBOption) 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 {
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 {
db, _ := getAlertDB(opts...)
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
}
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 {
ensureAlertConfigUID(config)
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) {
var configs []model.AlertConfig
db := global.AlertDB.Model(&model.AlertConfig{})
@@ -378,26 +479,44 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
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)
oldConfigByKey := make(map[string][]model.AlertConfig)
consumedConfigIDs := make(map[uint]struct{})
for _, item := range oldConfigs {
if strings.TrimSpace(item.UID) != "" {
oldConfigByUID[item.UID] = item
}
if singletonTypes[item.Type] {
oldConfigMap[item.Type] = item.ID
oldConfigMap[item.Type] = item
continue
}
oldConfigByType[item.Type] = append(oldConfigByType[item.Type], item)
oldConfigByKey[alertConfigSyncKey(item)] = append(oldConfigByKey[alertConfigSyncKey(item)], item)
}
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 val, ok := oldConfigMap[item.Type]; ok {
item.ID = val
if matched, ok := oldConfigMap[item.Type]; ok {
if err := inheritAlertConfigSyncState(&item, matched); err != nil {
tx.Rollback()
return err
}
delete(oldConfigMap, item.Type)
consumedConfigIDs[item.ID] = struct{}{}
} else {
item.ID = 0
ensureAlertConfigUID(&item)
if err := validateAlertConfigSyncSecret(&item); err != nil {
tx.Rollback()
return err
}
}
if item.ID == 0 {
if err := tx.Create(&item).Error; err != nil {
@@ -411,9 +530,31 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
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)
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{}{}
if err := tx.Save(&item).Error; err != nil {
tx.Rollback()
@@ -424,7 +565,12 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
}
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{}{}
if err := tx.Save(&item).Error; err != nil {
tx.Rollback()
@@ -434,6 +580,11 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
}
item.ID = 0
ensureAlertConfigUID(&item)
if err := validateAlertConfigSyncSecret(&item); err != nil {
tx.Rollback()
return err
}
if err := tx.Create(&item).Error; err != nil {
tx.Rollback()
return err
@@ -458,6 +609,63 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
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) {
var alerts []model.Alert
if err := tx.Select("method").Find(&alerts).Error; err != nil {
+1 -1
View File
@@ -108,7 +108,7 @@ func WithByAPIType(apiType string) DBOption {
func WithTextAPIType() DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("api_type NOT LIKE ?", "%-images")
return g.Where("api_type NOT LIKE ? AND api_type <> ?", "%-images", "openai-embeddings")
}
}
+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
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
GetCert(opts ...DBOption) (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
UpdateCert(id uint, vars map[string]interface{}) error
DeleteCert(opts ...DBOption) error
WithByChain(chain string) DBOption
}
func NewIHostRepo() IHostRepo {
@@ -116,65 +109,6 @@ func (h *HostRepo) Delete(opts ...DBOption) 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) {
var cert model.RootCert
db := global.DB
@@ -253,9 +187,3 @@ func (u *HostRepo) SyncCert(data []model.RootCert) error {
tx.Commit()
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
}
func (s *MonitorRepo) BatchCreateMonitorGPU(list []model.MonitorGPU) error {
if len(list) == 0 {
return nil
}
return global.GPUMonitorDB.CreateInBatches(&list, len(list)).Error
}
func (u *MonitorRepo) BatchCreateMonitorIO(ioList []model.MonitorIO) error {
+3 -2
View File
@@ -936,6 +936,7 @@ func (a AgentService) GetModelConfig(req dto.AgentIDReq) (*dto.AgentModelConfig,
AccountID: agent.AccountID,
Model: model,
Fallbacks: extractOpenclawFallbackModelIDs(conf, account, models, model),
Metadata: extractOpenclawModelMetadata(conf, account, models),
}, nil
}
@@ -967,7 +968,7 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
if agent.AgentType != constant.AppOpenclaw {
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
}
}
@@ -1684,7 +1685,7 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
return err
}
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
}
case constant.AppHermesAgent:
+18 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path"
"slices"
"sort"
"strings"
"time"
@@ -1320,6 +1321,10 @@ func appendPluginAllow(conf map[string]interface{}, pluginID string) {
}
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)
defer func() {
_ = mgr.Run("docker", "exec", containerName, "rm", "-rf", workdir)
@@ -1341,7 +1346,19 @@ func installOpenclawPlugin(mgr *cmd.CommandHelper, containerName, spec, pluginID
if pkgPath == "" {
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 {
+152 -7
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"path"
"regexp"
"slices"
"strconv"
"strings"
"time"
@@ -116,7 +117,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
}
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
if validateAvailability && (imageAPI || !providercatalog.SkipVerification(provider)) {
if validateAvailability && (imageAPI || providercatalog.IsEmbeddingAPIType(resolvedAPIType) || !providercatalog.SkipVerification(provider)) {
if err := providercatalog.VerifyAccount(provider, resolvedAPIType, resolvedAuthMode, resolvedBaseURL, resolvedAPIKey, modelID); err != nil {
return resolvedAgentAccountInput{}, err
}
@@ -737,9 +738,11 @@ type modelProvider struct {
}
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Input []string `json:"input,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Input []string `json:"input,omitempty"`
ContextWindow int `json:"contextWindow,omitempty"`
MaxTokens int `json:"maxTokens,omitempty"`
}
func requiresOpenclawProviderModels(provider string) bool {
@@ -767,7 +770,7 @@ type browserConfig struct {
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) == "" {
return fmt.Errorf("config dir is required")
}
@@ -852,6 +855,7 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
}
conf = initial
} else {
preserveOpenclawModelMetadata(conf, cfg.Models)
if err := applyOpenclawModelsConfig(conf, cfg.Models); err != nil {
return err
}
@@ -906,6 +910,9 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
if allowedOrigins != nil {
setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins})
}
if err := applyOpenclawModelMetadata(conf, account, metadata); err != nil {
return err
}
if err := writeOpenclawConfigRaw(configPath, conf); err != nil {
return err
}
@@ -920,6 +927,144 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
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) {
accountModels, err := loadAgentAccountModels(account)
if err != nil {
@@ -1041,7 +1186,7 @@ func prepareOpenclawInstallFiles(appInstall *model.AppInstall, account *model.Ag
return fmt.Errorf("app install is required")
}
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
}
dataDir := path.Join(appInstall.GetPath(), "data")
@@ -1338,7 +1483,7 @@ func normalizeAgentAccountModel(account *model.AgentAccount, model dto.AgentAcco
func requiresInitialAgentAccountModels(provider string) bool {
switch provider {
case "custom", "vllm", "ollama":
case "custom", "vllm", "ollama", "llmman":
return true
default:
return false
+329 -29
View File
@@ -17,10 +17,13 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"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/copier"
"github.com/1Panel-dev/1Panel/agent/utils/email"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/1Panel-dev/1Panel/agent/utils/xpack/providers"
"github.com/shirou/gopsutil/v4/disk"
)
@@ -34,6 +37,28 @@ var communityAlertMethodTypeNames = map[string]string{
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 {
PageAlert(req dto.AlertSearch) (int64, []dto.AlertDTO, error)
GetAlerts() ([]dto.AlertDTO, error)
@@ -53,8 +78,10 @@ type IAlertService interface {
GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertConfig, error)
PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error)
UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error
UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error
DeleteAlertConfig(id uint) error
TestAlertConfig(req dto.AlertConfigTest) (bool, error)
TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error)
}
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 {
if err := a.validateCommunityAlertMethod(req.Method); err != nil {
methodTypes, err := a.validateAlertMethodReferences(req.Method)
if err != nil {
return err
}
if req.Status != constant.AlertDisable {
if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
return err
}
}
upMap := make(map[string]interface{})
upMap["id"] = req.ID
@@ -240,7 +273,16 @@ func (a AlertService) UpdateStatus(id uint, status string) error {
if alertInfo.ID == 0 {
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 {
return err
}
@@ -412,6 +454,7 @@ func (a AlertService) parseAlertLog(item model.AlertLog) (dto.AlertLogDTO, error
if err := unmarshalAlertInfo(item.AlertDetail, &alertDetail); err != nil {
return dto.AlertLogDTO{}, err
}
alertDetail.Task = nil
if err := unmarshalAlertInfo(item.AlertRule, &alertRule); err != nil {
return dto.AlertLogDTO{}, err
}
@@ -494,7 +537,13 @@ func (a AlertService) GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertCon
}
opts = append(opts, repo.WithByStatus(constant.AlertEnable))
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) {
@@ -505,13 +554,49 @@ func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []mode
if len(req.ExcludeTypes) > 0 {
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 {
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 {
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 {
return err
}
@@ -526,7 +611,7 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
upMap["status"] = req.Status
upMap["config"] = req.Config
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
}
} else {
@@ -544,6 +629,99 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
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 {
if req.Type != constant.SMSConfig {
return nil
@@ -568,6 +746,9 @@ func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate)
}
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)
if displayName == "" {
return nil
@@ -591,37 +772,67 @@ func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdat
}
func (a AlertService) validateCommunityAlertMethod(method string) error {
if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" {
return nil
}
if strings.TrimSpace(method) == "" {
return nil
methodTypes, err := a.validateAlertMethodReferences(method)
if err != nil {
return err
}
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, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
configType := ""
if configID, err := strconv.ParseUint(item, 10, 64); err == nil {
config, err := alertRepo.GetConfigById(uint(configID))
if err != nil {
return err
return nil, err
}
if _, ok := communityAlertMethodTypeNames[config.Type]; ok {
return buserr.WithErr("ErrAlertMethodNotSupported", nil)
configType = config.Type
} 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
}
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 nil
}
func (a AlertService) validateCommunityAlertConfigType(configType string) error {
if configType == constant.Custom {
return nil
}
if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" {
return nil
}
@@ -633,7 +844,7 @@ func (a AlertService) validateCommunityAlertConfigType(configType string) error
func alertConfigDisplayName(configType, configData string) string {
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 {
DisplayName string `json:"displayName"`
}
@@ -672,20 +883,24 @@ func (a AlertService) DeleteAlertConfig(id uint) error {
}
func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
username := req.UserName
if username == "" {
username = req.Sender
emailConfig, err := resolveEmailTestConfig(req)
if err != nil {
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{
Host: req.Host,
Port: req.Port,
Sender: req.Sender,
Host: emailConfig.Host,
Port: emailConfig.Port,
Sender: emailConfig.Sender,
Username: username,
Password: req.Password,
From: fmt.Sprintf(`"%s" <%s>`, encodedDisplayName, req.Sender),
Encryption: req.Encryption,
Recipient: req.Recipient,
Password: emailConfig.Password,
From: fmt.Sprintf(`"%s" <%s>`, encodedDisplayName, emailConfig.Sender),
Encryption: emailConfig.Encryption,
Recipient: emailConfig.Recipient,
}
msg := email.EmailMessage{
@@ -700,9 +915,94 @@ func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
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 {
if err := a.validateCommunityAlertMethod(updateAlert.Method); err != nil {
return err
var methodTypes []string
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{})
var newStatus string
+101 -29
View File
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
"errors"
"fmt"
"math"
"net"
@@ -32,6 +33,7 @@ const (
ResourceAlertInterval = 30
CheckIntervalSec = 3
LoadCheckIntervalMin = 5
sshIPLoginWindow = 30 * time.Minute
)
type AlertTaskHelper struct {
@@ -512,10 +514,28 @@ func loadPanelLogin(alert dto.AlertDTO) {
}
func loadSSHLogin(alert dto.AlertDTO) {
count, isAlert, err := alertUtil.CountRecentFailedSSHLog(alert.Cycle, alert.Count)
if err != nil {
global.LOG.Errorf("Failed to count recent failed ssh login logs: %v", err)
now := time.Now()
failedWindow := time.Duration(alert.Cycle) * time.Minute
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 {
params := []dto.Param{
{
@@ -531,12 +551,6 @@ func loadSSHLogin(alert dto.AlertDTO) {
}
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 {
quota := strings.Join(records, "\n")
params := []dto.Param{
@@ -565,20 +579,6 @@ func filterLoginLogsNotInWhitelist(records []model.LoginLog, whitelist []string)
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 {
targetIP := net.ParseIP(strings.TrimSpace(ip))
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) {
typeMap := map[string]string{
"mail": constant.Email,
constant.Bark: constant.Bark,
constant.SMS: constant.SMS,
"mail": constant.Email,
constant.Bark: constant.Bark,
constant.SMS: constant.SMS,
constant.Custom: constant.Custom,
}
configType, ok := typeMap[method]
if !ok {
@@ -785,7 +786,7 @@ func doSendAlert(alert dto.AlertDTO, alertType, quota, quotaType string, params
}
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)
if !isValid {
return
@@ -798,12 +799,31 @@ func doSendAlert(alert dto.AlertDTO, alertType, quota, quotaType string, params
}
transport := xpack.MultiNodeProvider.LoadRequestTransport()
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 {
global.LOG.Infof("%s alert webhook %s push faild, err: %v", alertType, methodStr, alertErr)
return
}
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr)
if !queued {
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr)
}
}
}
@@ -1097,3 +1117,55 @@ func calculateMinutesDifference(newDate time.Time) int {
minutesDifference := int(now.Sub(newDate).Minutes())
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 -6
View File
@@ -75,7 +75,7 @@ func (s *AlertSender) sendByConfig(config model.AlertConfig, quota string, param
} else {
s.sendBarkWithConfig(config, quota, params)
}
case constant.WeCom, constant.DingTalk, constant.FeiShu:
case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Custom:
if isResource {
s.sendResourceWebhookWithConfig(config, quota, params)
} else {
@@ -86,7 +86,7 @@ func (s *AlertSender) sendByConfig(config model.AlertConfig, quota string, param
func (s *AlertSender) sendByLegacyMethod(method string, quota string, params []dto.Param, isResource bool) {
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
if mapped, ok := typeMap[method]; ok {
configType = mapped
@@ -308,12 +308,31 @@ func (s *AlertSender) sendWebhookWithConfig(config model.AlertConfig, quota stri
}
transport := xpack.MultiNodeProvider.LoadRequestTransport()
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 {
global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, config.Type, err)
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) {
@@ -334,11 +353,31 @@ func (s *AlertSender) sendResourceWebhookWithConfig(config model.AlertConfig, qu
}
transport := xpack.MultiNodeProvider.LoadRequestTransport()
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)
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) {
+20 -13
View File
@@ -223,6 +223,9 @@ func (a AppService) GetAppDetailByKey(appKey, version string) (response.AppDetai
if err != nil {
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))
if err != nil {
return appDetailDTO, err
@@ -241,14 +244,17 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err != nil {
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.Enable = true
if appType == "runtime" {
app, err := appRepo.GetFirst(repo.WithByID(appID))
if err != nil {
return appDetailDTO, err
}
fileOp := files.NewFileOp()
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
@@ -319,10 +325,6 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
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 {
appDetailDTO.Enable = false
}
@@ -374,6 +376,9 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
if err != nil {
return
}
if err = checkVllmVersionAccess(app.Key, appDetail.Version); err != nil {
return
}
if DatabaseKeys[app.Key] > 0 {
if existDatabases, _ := databaseRepo.GetList(repo.WithByName(req.Name)); len(existDatabases) > 0 {
err = buserr.New("ErrRemoteExist")
@@ -483,15 +488,17 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
index++
}
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]
delete(servicesMap, serviceName)
serviceName = newServiceName
}
appInstall.ServiceName = serviceName
if err = addDockerComposeCommonParam(composeMap, appInstall.ServiceName, req.AppContainerConfig, req.Params); err != nil {
return
if !req.SkipComposeCommonConfig {
if err = addDockerComposeCommonParam(composeMap, appInstall.ServiceName, req.AppContainerConfig, req.Params); err != nil {
return
}
}
var (
composeByte []byte
@@ -559,7 +566,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err
}
}
if executeScript {
if executeScript || req.UseLifecycleScripts {
if err = runScript(t, appInstall, "init"); err != nil {
return err
}
@@ -572,7 +579,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err
}
}
if err = upApp(t, appInstall, req.PullImage); err != nil {
if err = upApp(t, appInstall, req.PullImage, req.UseLifecycleScripts); err != nil {
return err
}
updateToolApp(appInstall)
+110 -13
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"math"
"net/http"
"os"
@@ -13,12 +14,14 @@ import (
"sort"
"strconv"
"strings"
"time"
"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/response"
"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"
@@ -252,6 +255,9 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return buserr.New("ErrInstallDirNotFound")
}
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 {
case constant.Rebuild:
return rebuildApp(install)
@@ -275,12 +281,13 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return syncAppInstallStatus(&install, false)
case constant.Delete:
deleteReq := request.AppInstallDelete{
Install: install,
DeleteBackup: req.DeleteBackup,
ForceDelete: req.ForceDelete,
DeleteDB: req.DeleteDB,
DeleteImage: req.DeleteImage,
TaskID: req.TaskID,
Install: install,
DeleteBackup: req.DeleteBackup,
ForceDelete: req.ForceDelete,
DeleteDB: req.DeleteDB,
DeleteImage: req.DeleteImage,
TaskID: req.TaskID,
UseLifecycleScripts: req.UseLifecycleScripts,
}
if err = deleteAppInstall(deleteReq); err != nil && !req.ForceDelete {
return err
@@ -312,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 {
installed, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if err != nil {
@@ -374,8 +445,10 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
return err
}
}
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
return err
if !req.SkipComposeCommonConfig {
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
return err
}
}
composeByte, err := yaml.Marshal(composeMap)
if err != nil {
@@ -408,7 +481,7 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
if err != nil {
return err
}
backupEnvMaps := oldEnvMaps
backupEnvMaps := maps.Clone(oldEnvMaps)
handleMap(req.Params, oldEnvMaps)
paramByte, err := json.Marshal(oldEnvMaps)
if err != nil {
@@ -420,13 +493,32 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
}
fileOp := files.NewFileOp()
_ = fileOp.WriteFile(installed.GetComposePath(), strings.NewReader(installed.DockerCompose), constant.DirPerm)
if err := rebuildApp(installed); err != nil {
restoreConfig := func(operationErr error) {
_ = env.Write(backupEnvMaps, envPath)
_ = 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
}
installed.Status = constant.StatusRunning
_ = appInstallRepo.Save(context.Background(), &installed)
if !req.UseLifecycleScripts {
installed.Status = constant.StatusRunning
_ = appInstallRepo.Save(context.Background(), &installed)
}
proxyChanged := hasAppInstallProxyPassChanged(&oldInstalled, &installed)
currentProxy, currentProxyErr := getAppInstallProxyPass(&installed)
@@ -583,6 +675,9 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
return versions, err
}
for _, detail := range details {
if !canAccessVllmVersion(app.Key, detail.Version) {
continue
}
ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(detail.ID), appIgnoreUpgradeRepo.WithScope("version"))
if len(ignores) > 0 {
continue
@@ -836,7 +931,9 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, 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
}
cli, err := docker.NewClient()
+28 -5
View File
@@ -107,6 +107,9 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
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")
}
@@ -426,7 +429,7 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
t.LogStart(logStr)
if out, upErr := compose.UpWithoutPull(u.original.GetComposePath()); upErr != nil {
if out, upErr := compose.UpWithoutBuild(u.original.GetComposePath()); upErr != nil {
if out != "" {
upErr = fmt.Errorf("%s: %w", out, upErr)
}
@@ -459,6 +462,13 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
}); 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
}
@@ -513,7 +523,6 @@ func (u *appUpgradeContext) applyStagedFiles() error {
nginxModuleBuildDir,
nginxModuleModulesDir,
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
path.Join(nginxModuleConfDir, "nginx.conf"),
} {
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), relativePath); err != nil {
return err
@@ -546,7 +555,7 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
return u.finishRollback()
}
if u.phase < appUpgradeMutated {
if out, err := compose.UpWithoutPull(u.original.GetComposePath()); err != nil {
if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
@@ -564,14 +573,14 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if u.backupFile != "" {
_ = u.restoreManagedFiles()
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
_, _ = compose.UpWithoutPull(u.original.GetComposePath())
_, _ = 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.UpWithoutPull(u.original.GetComposePath()); err != nil {
if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
@@ -716,6 +725,20 @@ func renderUpgradeEnv(install *model.AppInstall, original []byte) ([]byte, error
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 {
+33 -10
View File
@@ -353,15 +353,21 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
logStr := i18n.GetMsgByKey("Stop") + i18n.GetMsgByKey("App")
t.Log(logStr)
out, err := compose.Down(install.GetComposePath())
if err != nil && !deleteReq.ForceDelete {
return handleErr(install, err, out)
if deleteReq.UseLifecycleScripts {
if err = runScript(t, &install, "uninstall"); err != nil {
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)
if err = runScript(t, &install, "uninstall"); err != nil {
_, _ = compose.Up(install.GetComposePath())
return err
}
if deleteReq.DeleteImage {
content, err := op.GetContent(install.GetEnvPath())
if err != nil {
@@ -999,6 +1005,12 @@ func runScript(task *task.Task, appInstall *model.AppInstall, operate string) er
scriptPath = path.Join(workDir, "scripts", "upgrade.sh")
case "uninstall":
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()
if !fileOp.Stat(scriptPath) {
@@ -1008,7 +1020,11 @@ func runScript(task *task.Task, appInstall *model.AppInstall, operate string) er
logStr := i18n.GetWithName("ExecShell", operate)
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 {
task.LogFailedWithErr(logStr, err)
return err
@@ -1043,12 +1059,15 @@ func checkContainerNameIsExist(containerName, appDir string) (bool, error) {
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) {
var (
out string
errMsg string
)
if useLifecycleScripts {
return runScript(task, appInstall, "start")
}
if pullImages && appInstall.App.Type != "php" {
envByte, err := files.NewFileOp().GetContent(appInstall.GetEnvPath())
if err != nil {
@@ -1375,7 +1394,8 @@ func handleErr(install model.AppInstall, err error, out string) error {
func doNotNeedSync(installed model.AppInstall) bool {
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) {
@@ -2229,6 +2249,9 @@ func getAppVersions(key string, details []model.AppDetail) []string {
hasLatest := false
latestVersion := ""
for _, detail := range details {
if !canAccessVllmVersion(key, detail.Version) {
continue
}
if key != "mssql" && strings.Contains(detail.Version, "latest") {
hasLatest = true
latestVersion = detail.Version
+1 -1
View File
@@ -232,7 +232,7 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
}
defer func() {
if isRollback {
_, _ = compose.UpWithoutPull(install.GetComposePath())
_, _ = compose.UpWithoutBuild(install.GetComposePath())
} else {
_, _ = compose.Up(install.GetComposePath())
}
+28 -54
View File
@@ -582,6 +582,10 @@ func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.T
if config.Image == "" {
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 err := pullImages(taskItem, recoverCtx.client, config.Image); err != nil {
return err
@@ -596,7 +600,7 @@ func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.T
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 {
return err
}
@@ -604,7 +608,7 @@ func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.T
return nil
}
func normalizeContainerEndpointSettings(ctx context.Context, 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)
}
@@ -619,11 +623,14 @@ func normalizeContainerEndpointSettings(ctx context.Context, cli *client.Client,
}
info, err := cli.NetworkInspect(ctx, netName, network.InspectOptions{})
if err != nil {
continue
return fmt.Errorf("inspect network %s failed: %w", netName, err)
}
if err := validateContainerEndpointStaticIP(netName, info, endpoint); err != nil {
return err
}
removeUnsupportedEndpointStaticIP(netName, info, endpoint)
}
}
return nil
}
func removeEndpointMacAddresses(primary *network.NetworkingConfig, extras map[string]*network.EndpointSettings) {
@@ -641,24 +648,28 @@ func removeEndpointMacAddresses(primary *network.NetworkingConfig, extras map[st
}
}
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 {
return
return nil
}
if isDefaultBridgeNetwork(netName, info) {
endpoint.IPAMConfig = nil
return
ipam := endpoint.IPAMConfig
if err := ipam.Validate(); err != nil {
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) {
endpoint.IPAMConfig.IPv4Address = ""
if ipam.IPv4Address != "" && !networkSupportsStaticIP(info, ipam.IPv4Address, false) {
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) {
endpoint.IPAMConfig.IPv6Address = ""
}
if endpoint.IPAMConfig.IPv4Address == "" && endpoint.IPAMConfig.IPv6Address == "" && len(endpoint.IPAMConfig.LinkLocalIPs) == 0 {
endpoint.IPAMConfig = nil
if ipam.IPv6Address != "" && !networkSupportsStaticIP(info, ipam.IPv6Address, true) {
return fmt.Errorf("static IPv6 address %s is not in a configured subnet of network %s", ipam.IPv6Address, netName)
}
return nil
}
func isDefaultBridgeNetwork(netName string, info network.Inspect) bool {
@@ -673,6 +684,7 @@ func networkSupportsStaticIP(info network.Inspect, ip string, isIPv6 bool) bool
if err != nil {
return false
}
addr = addr.Unmap()
if addr.Is6() != isIPv6 {
return false
}
@@ -813,11 +825,6 @@ func buildContainerRecoverNetworkConfig(networkSettings *container.NetworkSettin
IPv6Address: endpoint.IPAMConfig.IPv6Address,
LinkLocalIPs: append([]string(nil), endpoint.IPAMConfig.LinkLocalIPs...),
}
} else if name != "bridge" && (endpoint.IPAddress != "" || endpoint.GlobalIPv6Address != "") {
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: endpoint.IPAddress,
IPv6Address: endpoint.GlobalIPv6Address,
}
}
if name == primaryName {
config.EndpointsConfig[name] = endpointSetting
@@ -831,39 +838,6 @@ func buildContainerRecoverNetworkConfig(networkSettings *container.NetworkSettin
return config, extraNetworks
}
const unsupportedUserSpecifiedIPAddress = "user specified IP address is supported only when connecting to networks with user configured subnets"
func clearUnsupportedDynamicEndpointIPAM(err error, endpoints map[string]*network.EndpointSettings, networkSettings *container.NetworkSettings) bool {
if err == nil || !strings.Contains(err.Error(), unsupportedUserSpecifiedIPAddress) {
return false
}
for name, endpoint := range endpoints {
if !isDynamicContainerNetwork(networkSettings, name) || endpoint == nil || endpoint.IPAMConfig == nil {
continue
}
if strings.Contains(err.Error(), "network "+name+":") {
endpoint.IPAMConfig = nil
return true
}
}
cleared := false
for name, endpoint := range endpoints {
if isDynamicContainerNetwork(networkSettings, name) && endpoint != nil && endpoint.IPAMConfig != nil {
endpoint.IPAMConfig = nil
cleared = true
}
}
return cleared
}
func isDynamicContainerNetwork(networkSettings *container.NetworkSettings, name string) bool {
if networkSettings == nil || name == "bridge" {
return false
}
endpoint := networkSettings.Networks[name]
return endpoint != nil && endpoint.IPAMConfig == nil && (endpoint.IPAddress != "" || endpoint.GlobalIPv6Address != "")
}
func cloneContainerConfig(config *container.Config) *container.Config {
if config == nil {
return &container.Config{}
+59 -130
View File
@@ -16,6 +16,7 @@ import (
"path"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
@@ -24,6 +25,7 @@ import (
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
@@ -79,7 +81,7 @@ type IContainerService interface {
ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
ContainerListStats() ([]dto.ContainerListStats, error)
ContainerItemStats(req dto.OperationWithName) (dto.ContainerItemStats, error)
ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
LoadResourceLimit() (*dto.ResourceLimit, error)
ContainerRename(req dto.ContainerRename) error
ContainerCommit(req dto.ContainerCommit) error
@@ -246,15 +248,15 @@ func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) {
}
return data, nil
}
func (u *ContainerService) ContainerItemStats(req dto.OperationWithName) (dto.ContainerItemStats, error) {
func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error) {
var data dto.ContainerItemStats
client, err := docker.NewDockerClient()
if err != nil {
return data, err
}
defer client.Close()
if req.Name != "system" {
defer client.Close()
containerInfo, _, err := client.ContainerInspectWithRaw(context.Background(), req.Name, true)
containerInfo, _, err := client.ContainerInspectWithRaw(ctx, req.Name, true)
if err != nil {
return data, err
}
@@ -263,7 +265,7 @@ func (u *ContainerService) ContainerItemStats(req dto.OperationWithName) (dto.Co
return data, nil
}
usage, err := client.DiskUsage(context.Background(), types.DiskUsageOptions{})
usage, err := client.DiskUsage(ctx, types.DiskUsageOptions{})
if err != nil {
return data, err
}
@@ -534,7 +536,9 @@ func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bo
if err != nil {
return err
}
normalizeContainerEndpointSettings(ctx, client, networkConf, nil)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, nil); err != nil {
return err
}
con, err := client.ContainerCreate(ctx, config, hostConf, networkConf, &v1.Platform{}, req.Name)
if err != nil {
taskItem.Log(i18n.GetMsgByKey("ContainerCreateFailed"))
@@ -644,14 +648,9 @@ func loadContainerNetworkInfo(name string, endpoint *network.EndpointSettings) d
if endpoint.IPAMConfig != nil {
item.LinkLocalIPs = append([]string(nil), endpoint.IPAMConfig.LinkLocalIPs...)
}
if name != "bridge" {
if endpoint.IPAMConfig != nil {
item.Ipv4 = endpoint.IPAMConfig.IPv4Address
item.Ipv6 = endpoint.IPAMConfig.IPv6Address
} else {
item.Ipv4 = endpoint.IPAddress
item.Ipv6 = endpoint.GlobalIPv6Address
}
if name != "bridge" && endpoint.IPAMConfig != nil {
item.Ipv4 = endpoint.IPAMConfig.IPv4Address
item.Ipv6 = endpoint.IPAMConfig.IPv6Address
}
return item
}
@@ -1678,30 +1677,42 @@ func checkImageLike(client *client.Client, imageName string) bool {
func pullImages(task *task.Task, client *client.Client, imageName string) error {
dockerCli := docker.NewClientWithExist(client)
repos, err := imageRepoRepo.List()
if err != nil {
return err
}
imageRepo := selectImageRepo(imageName, repos)
if imageRepo == nil || !imageRepo.Auth {
return dockerCli.PullImageWithProcess(task, imageName)
}
options := image.PullOptions{}
repos, _ := imageRepoRepo.List()
if len(repos) != 0 {
for _, repo := range repos {
if strings.HasPrefix(imageName, repo.DownloadUrl) && repo.Auth {
authConfig := registry.AuthConfig{
Username: repo.Username,
Password: repo.Password,
}
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
return err
}
authStr := base64.URLEncoding.EncodeToString(encodedJSON)
options.RegistryAuth = authStr
}
authConfig := registry.AuthConfig{
Username: imageRepo.Username,
Password: imageRepo.Password,
}
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
return err
}
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
return dockerCli.PullImageWithProcessAndOptions(task, imageName, options)
}
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
var selected *model.ImageRepo
selectedURLLength := 0
for i := range repos {
downloadURL := strings.TrimRight(strings.TrimSpace(repos[i].DownloadUrl), "/")
if downloadURL == "" || !strings.HasPrefix(imageName, downloadURL+"/") {
continue
}
} else {
hasAuth, authStr := loadAuthInfo(imageName)
if hasAuth {
options.RegistryAuth = authStr
if len(downloadURL) > selectedURLLength {
selected = &repos[i]
selectedURLLength = len(downloadURL)
}
}
return dockerCli.PullImageWithProcessAndOptions(task, imageName, options)
return selected
}
func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerListStats {
@@ -1758,7 +1769,10 @@ func checkPortStats(ports []dto.PortHelper, checkInUse bool) (nat.PortMap, error
}
for i := 0; i <= hostEnd-hostStart; i++ {
bindItem := nat.PortBinding{HostPort: strconv.Itoa(hostStart + i), HostIP: port.HostIP}
portMap[nat.Port(fmt.Sprintf("%d/%s", containerStart+i, port.Protocol))] = []nat.PortBinding{bindItem}
portKey := nat.Port(fmt.Sprintf("%d/%s", containerStart+i, port.Protocol))
if !slices.Contains(portMap[portKey], bindItem) {
portMap[portKey] = append(portMap[portKey], bindItem)
}
}
for i := hostStart; i <= hostEnd; i++ {
if checkInUse && common.ScanPortWithIP(port.HostIP, i) {
@@ -1776,7 +1790,10 @@ func checkPortStats(ports []dto.PortHelper, checkInUse bool) (nat.PortMap, error
return portMap, buserr.WithDetail("ErrPortInUsed", portItem, nil)
}
bindItem := nat.PortBinding{HostPort: strconv.Itoa(portItem), HostIP: port.HostIP}
portMap[nat.Port(fmt.Sprintf("%s/%s", port.ContainerPort, port.Protocol))] = []nat.PortBinding{bindItem}
portKey := nat.Port(fmt.Sprintf("%s/%s", port.ContainerPort, port.Protocol))
if !slices.Contains(portMap[portKey], bindItem) {
portMap[portKey] = append(portMap[portKey], bindItem)
}
}
}
return portMap, nil
@@ -1922,90 +1939,7 @@ func loadPortByInspect(id string, client *client.Client) ([]container.Port, erro
return itemPorts, nil
}
func transPortToStr(ports []container.Port) []string {
var (
ipv4Ports []container.Port
ipv6Ports []container.Port
)
for _, port := range ports {
if strings.Contains(port.IP, ":") {
ipv6Ports = append(ipv6Ports, port)
} else {
ipv4Ports = append(ipv4Ports, port)
}
}
list1 := simplifyPort(ipv4Ports)
list2 := simplifyPort(ipv6Ports)
return append(list1, list2...)
}
func simplifyPort(ports []container.Port) []string {
var datas []string
if len(ports) == 0 {
return datas
}
if len(ports) == 1 {
ip := ""
if len(ports[0].IP) != 0 {
ip = ports[0].IP + ":"
}
itemPortStr := fmt.Sprintf("%s%v/%s", ip, ports[0].PrivatePort, ports[0].Type)
if ports[0].PublicPort != 0 {
itemPortStr = fmt.Sprintf("%s%v->%v/%s", ip, ports[0].PublicPort, ports[0].PrivatePort, ports[0].Type)
}
datas = append(datas, itemPortStr)
return datas
}
sort.Slice(ports, func(i, j int) bool {
return ports[i].PrivatePort < ports[j].PrivatePort
})
start := ports[0]
for i := 1; i < len(ports); i++ {
if ports[i].PrivatePort != ports[i-1].PrivatePort+1 || ports[i].IP != ports[i-1].IP || ports[i].PublicPort != ports[i-1].PublicPort+1 || ports[i].Type != ports[i-1].Type {
if ports[i-1].PrivatePort == start.PrivatePort {
itemPortStr := fmt.Sprintf("%s:%v/%s", start.IP, start.PrivatePort, start.Type)
if start.PublicPort != 0 {
itemPortStr = fmt.Sprintf("%s:%v->%v/%s", start.IP, start.PublicPort, start.PrivatePort, start.Type)
}
if len(start.IP) == 0 {
itemPortStr = strings.TrimPrefix(itemPortStr, ":")
}
datas = append(datas, itemPortStr)
} else {
itemPortStr := fmt.Sprintf("%s:%v-%v/%s", start.IP, start.PrivatePort, ports[i-1].PrivatePort, start.Type)
if start.PublicPort != 0 {
itemPortStr = fmt.Sprintf("%s:%v-%v->%v-%v/%s", start.IP, start.PublicPort, ports[i-1].PublicPort, start.PrivatePort, ports[i-1].PrivatePort, start.Type)
}
if len(start.IP) == 0 {
itemPortStr = strings.TrimPrefix(itemPortStr, ":")
}
datas = append(datas, itemPortStr)
}
start = ports[i]
}
if i == len(ports)-1 {
if ports[i].PrivatePort == start.PrivatePort {
itemPortStr := fmt.Sprintf("%s:%v/%s", start.IP, start.PrivatePort, start.Type)
if start.PublicPort != 0 {
itemPortStr = fmt.Sprintf("%s:%v->%v/%s", start.IP, start.PublicPort, start.PrivatePort, start.Type)
}
if len(start.IP) == 0 {
itemPortStr = strings.TrimPrefix(itemPortStr, ":")
}
datas = append(datas, itemPortStr)
} else {
itemPortStr := fmt.Sprintf("%s:%v-%v/%s", start.IP, start.PrivatePort, ports[i].PrivatePort, start.Type)
if start.PublicPort != 0 {
itemPortStr = fmt.Sprintf("%s:%v-%v->%v-%v/%s", start.IP, start.PublicPort, ports[i].PublicPort, start.PrivatePort, ports[i].PrivatePort, start.Type)
}
if len(start.IP) == 0 {
itemPortStr = strings.TrimPrefix(itemPortStr, ":")
}
datas = append(datas, itemPortStr)
}
}
}
return datas
return docker.SimplifyPorts(ports)
}
func loadComposeCount(client *client.Client) int {
@@ -2038,7 +1972,7 @@ func loadComposeCount(client *client.Client) int {
}
func loadContainerPortForInfo(itemPorts []container.Port) []dto.PortHelper {
var exposedPorts []dto.PortHelper
samePortMap := make(map[string]dto.PortHelper)
seenPorts := make(map[dto.PortHelper]struct{})
ports := transPortToStr(itemPorts)
for _, item := range ports {
itemStr := strings.Split(item, "->")
@@ -2059,16 +1993,11 @@ func loadContainerPortForInfo(itemPorts []container.Port) []dto.PortHelper {
}
itemPort.ContainerPort = itemContainer[0]
itemPort.Protocol = itemContainer[1]
keyItem := fmt.Sprintf("%s->%s/%s", itemPort.HostPort, itemPort.ContainerPort, itemPort.Protocol)
if val, ok := samePortMap[keyItem]; ok {
val.HostIP = ""
samePortMap[keyItem] = val
} else {
samePortMap[keyItem] = itemPort
if _, exists := seenPorts[itemPort]; exists {
continue
}
}
for _, val := range samePortMap {
exposedPorts = append(exposedPorts, val)
seenPorts[itemPort] = struct{}{}
exposedPorts = append(exposedPorts, itemPort)
}
return exposedPorts
}
+17 -12
View File
@@ -28,6 +28,7 @@ import (
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"gopkg.in/yaml.v3"
"gorm.io/gorm"
)
const composeProjectLabel = "com.docker.compose.project"
@@ -252,6 +253,10 @@ func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
return err
}
req.Name = projectName
recordName := strings.ToLower(req.Name)
if err := saveComposeRecord(recordName, req.Path); err != nil {
return fmt.Errorf("save compose record failed, err: %v", err)
}
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskCreate, task.TaskScopeCompose, req.TaskID, 1)
if err != nil {
return fmt.Errorf("new task for image build failed, err: %v", err)
@@ -260,18 +265,7 @@ func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
taskItem.AddSubTask(i18n.GetMsgByKey("ComposeCreate"), func(t *task.Task) error {
err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name)
t.LogWithStatus(i18n.GetMsgByKey("ComposeCreate"), err)
if err != nil {
_, _ = compose.Down(req.Path, req.Name)
return err
}
recordName := strings.ToLower(req.Name)
record, _ := composeRepo.GetRecord(repo.WithByName(recordName))
if record.ID == 0 {
_ = composeRepo.CreateRecord(&model.Compose{Name: recordName, Path: req.Path})
} else {
_ = composeRepo.UpdateRecord(recordName, map[string]interface{}{"path": req.Path})
}
return nil
return err
}, nil)
_ = taskItem.Execute()
}()
@@ -279,6 +273,17 @@ func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
return nil
}
func saveComposeRecord(name, composePath string) error {
record, err := composeRepo.GetRecord(repo.WithByName(name))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if record.ID == 0 {
return composeRepo.CreateRecord(&model.Compose{Name: name, Path: composePath})
}
return composeRepo.UpdateRecord(name, map[string]interface{}{"path": composePath})
}
func checkComposeRecordName(name string) error {
composeItem, _ := composeRepo.GetRecord(repo.WithByName(name))
if composeItem.ID != 0 && len(composeItem.Path) != 0 {
+76 -57
View File
@@ -1,9 +1,11 @@
package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"sort"
"strings"
"sync"
@@ -18,6 +20,7 @@ import (
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
@@ -64,13 +67,13 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
if err != nil {
return err
}
normalizeContainerEndpointSettings(ctx, client, networkConf, nil)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, nil); err != nil {
return err
}
cleanupErr, err := switchContainer(ctx, client, req.Name, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithDynamicIPFallback(func() (container.CreateResponse, error) {
return client.ContainerCreate(ctx, config, hostConf, networkConf, &v1.Platform{}, req.Name)
}, networkConf.EndpointsConfig, oldContainer.NetworkSettings)
}, newContainerSwitchTaskLogger(t))
return client.ContainerCreate(ctx, config, hostConf, networkConf, &v1.Platform{}, req.Name)
}, config.Tty, t)
if err != nil {
return fmt.Errorf("update container failed, err: %v", err)
}
@@ -135,9 +138,15 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
config.Image = req.Image
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
cleanupErr, err := switchContainer(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithOldNetworks(ctx, client, config, hostConf, oldContainer.NetworkSettings, item)
}, newContainerSwitchTaskLogger(t))
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
}, config.Tty, t)
if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
@@ -166,6 +175,7 @@ type containerSwitchClient interface {
ContainerStart(context.Context, string, container.StartOptions) error
ContainerRemove(context.Context, string, container.RemoveOptions) error
ContainerInspect(context.Context, string) (container.InspectResponse, error)
ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error)
NetworkConnect(context.Context, string, string, *network.EndpointSettings) error
NetworkDisconnect(context.Context, string, string, bool) error
}
@@ -238,22 +248,18 @@ func (l *containerOperationMutex) lock(names ...string) func() {
}
type containerNetworkAttachment struct {
name string
endpoint *network.EndpointSettings
isDynamic bool
name string
endpoint *network.EndpointSettings
}
type containerSwitchLogFunc func(messageKey, containerName string, err error)
func newContainerSwitchTaskLogger(t *task.Task) containerSwitchLogFunc {
return func(messageKey, containerName string, err error) {
t.LogWithStatus(i18n.GetWithName(messageKey, containerName), err)
}
type containerSwitchLogger interface {
LogWithStatus(string, error)
Log(string)
}
func logContainerSwitchStep(logger containerSwitchLogFunc, messageKey, containerName string, err error) {
func logContainerSwitchStep(logger containerSwitchLogger, messageKey, containerName string, err error) {
if logger != nil {
logger(messageKey, containerName, err)
logger.LogWithStatus(i18n.GetWithName(messageKey, containerName), err)
}
}
@@ -264,7 +270,8 @@ func switchContainer(
name string,
oldContainer container.InspectResponse,
createNew func() (container.CreateResponse, error),
logger containerSwitchLogFunc,
tty bool,
logger containerSwitchLogger,
) (cleanupErr error, err error) {
if oldContainer.ID == "" {
return nil, fmt.Errorf("original container ID is empty")
@@ -314,6 +321,7 @@ func switchContainer(
}
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
}
@@ -321,6 +329,7 @@ func switchContainer(
if wasRunning {
if err := waitContainerReady(ctx, cli, created.ID); err != nil {
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
}
@@ -337,8 +346,48 @@ const (
containerStartPollInterval = time.Second
containerHealthCheckMinWait = 30 * time.Second
containerHealthCheckMaxWait = 10 * time.Minute
containerDiagnosticLogTail = "200"
)
func logContainerStartupLogs(ctx context.Context, cli containerSwitchClient, containerID, name string, tty bool, logger containerSwitchLogger) {
if logger == nil {
return
}
logger.Log(fmt.Sprintf("========== %s ==========", i18n.GetWithName("ContainerStartupDiagnostic", name)))
diagnosticCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
reader, err := cli.ContainerLogs(diagnosticCtx, containerID, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Timestamps: true,
Tail: containerDiagnosticLogTail,
})
if err != nil {
logger.Log(i18n.GetWithNameAndErr("ContainerDiagnosticLogsFailed", name, err))
return
}
defer reader.Close()
var output bytes.Buffer
if tty {
_, err = io.Copy(&output, reader)
} else {
_, err = stdcopy.StdCopy(&output, &output, reader)
}
if err != nil {
logger.Log(i18n.GetWithNameAndErr("ContainerDiagnosticLogsFailed", name, err))
return
}
logs := strings.TrimSpace(output.String())
logger.Log(fmt.Sprintf("---------- %s ----------", i18n.GetMsgByKey("ContainerRecentLogs")))
if logs == "" {
logger.Log(i18n.GetMsgByKey("ContainerDiagnosticLogsEmpty"))
return
}
logger.Log(logs)
}
func waitContainerReady(ctx context.Context, cli containerInspectClient, containerID string) error {
info, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
@@ -538,13 +587,13 @@ func disconnectOriginalContainerNetworks(ctx context.Context, cli containerSwitc
endpoints := make(map[string]*network.EndpointSettings, len(extras)+1)
if primary != nil {
for name, endpoint := range primary.EndpointsConfig {
if name != "bridge" && endpoint != nil && endpoint.IPAMConfig != nil {
if name != "bridge" && endpoint != nil {
endpoints[name] = endpoint
}
}
}
for name, endpoint := range extras {
if name != "bridge" && endpoint != nil && endpoint.IPAMConfig != nil {
if name != "bridge" && endpoint != nil {
endpoints[name] = endpoint
}
}
@@ -560,9 +609,8 @@ func disconnectOriginalContainerNetworks(ctx context.Context, cli containerSwitc
return disconnected, fmt.Errorf("disconnect original container from network %s failed: %w", name, err)
}
disconnected = append(disconnected, containerNetworkAttachment{
name: name,
endpoint: endpoints[name],
isDynamic: isDynamicContainerNetwork(oldContainer.NetworkSettings, name),
name: name,
endpoint: endpoints[name],
})
}
return disconnected, nil
@@ -572,10 +620,6 @@ func reconnectOriginalContainerNetworks(ctx context.Context, cli containerSwitch
var reconnectErr error
for _, attachment := range attachments {
err := cli.NetworkConnect(ctx, attachment.name, containerID, attachment.endpoint)
if err != nil && attachment.isDynamic && strings.Contains(err.Error(), unsupportedUserSpecifiedIPAddress) {
attachment.endpoint.IPAMConfig = nil
err = cli.NetworkConnect(ctx, attachment.name, containerID, attachment.endpoint)
}
if err != nil {
reconnectErr = errors.Join(reconnectErr, fmt.Errorf("reconnect original container to network %s failed: %w", attachment.name, err))
}
@@ -583,7 +627,7 @@ func reconnectOriginalContainerNetworks(ctx context.Context, cli containerSwitch
return reconnectErr
}
func restoreOriginalContainer(ctx context.Context, cli containerSwitchClient, oldContainerID, originalName string, wasRunning bool, newContainer string, disconnectedNetworks []containerNetworkAttachment, logger containerSwitchLogFunc) error {
func restoreOriginalContainer(ctx context.Context, cli containerSwitchClient, oldContainerID, originalName string, wasRunning bool, newContainer string, disconnectedNetworks []containerNetworkAttachment, logger containerSwitchLogger) error {
var rollbackErr error
backupName := containerSwitchBackupName(oldContainerID)
if newContainer != "" {
@@ -608,7 +652,7 @@ func restoreOriginalContainer(ctx context.Context, cli containerSwitchClient, ol
reconnectErr := reconnectOriginalContainerNetworks(ctx, cli, oldContainerID, disconnectedNetworks)
logContainerSwitchStep(logger, "ContainerRollbackReconnectOld", currentName, reconnectErr)
rollbackErr = errors.Join(rollbackErr, reconnectErr)
if wasRunning {
if wasRunning && reconnectErr == nil {
restartErr := restartOriginalContainer(ctx, cli, oldContainerID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", currentName, restartErr)
rollbackErr = errors.Join(rollbackErr, restartErr)
@@ -616,17 +660,8 @@ func restoreOriginalContainer(ctx context.Context, cli containerSwitchClient, ol
return rollbackErr
}
func createContainerWithOldNetworks(ctx context.Context, client *client.Client, config *container.Config, hostConf *container.HostConfig, networkSettings *container.NetworkSettings, name string) (container.CreateResponse, error) {
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(networkSettings, hostConf)
normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks)
var primaryEndpoints map[string]*network.EndpointSettings
if networkConf != nil {
primaryEndpoints = networkConf.EndpointsConfig
}
created, err := createContainerWithDynamicIPFallback(func() (container.CreateResponse, error) {
return client.ContainerCreate(ctx, config, hostConf, networkConf, nil, name)
}, primaryEndpoints, networkSettings)
func createContainerWithNetworks(ctx context.Context, client *client.Client, config *container.Config, hostConf *container.HostConfig, networkConf *network.NetworkingConfig, extraNetworks map[string]*network.EndpointSettings, name string) (container.CreateResponse, error) {
created, err := client.ContainerCreate(ctx, config, hostConf, networkConf, nil, name)
if err != nil {
return created, err
}
@@ -638,9 +673,6 @@ func createContainerWithOldNetworks(ctx context.Context, client *client.Client,
sort.Strings(extraNames)
for _, item := range extraNames {
err := client.NetworkConnect(ctx, item, created.ID, extraNetworks[item])
if clearUnsupportedDynamicEndpointIPAM(err, map[string]*network.EndpointSettings{item: extraNetworks[item]}, networkSettings) {
err = client.NetworkConnect(ctx, item, created.ID, extraNetworks[item])
}
if err != nil {
_ = client.ContainerRemove(ctx, created.ID, container.RemoveOptions{Force: true})
return created, err
@@ -648,16 +680,3 @@ func createContainerWithOldNetworks(ctx context.Context, client *client.Client,
}
return created, nil
}
func createContainerWithDynamicIPFallback(
create func() (container.CreateResponse, error),
endpoints map[string]*network.EndpointSettings,
networkSettings *container.NetworkSettings,
) (container.CreateResponse, error) {
for {
created, err := create()
if err == nil || created.ID != "" || !clearUnsupportedDynamicEndpointIPAM(err, endpoints, networkSettings) {
return created, err
}
}
}
+58 -54
View File
@@ -18,8 +18,7 @@ import (
"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/utils/ai_tools/gpu"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/xpu"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/controller"
@@ -244,8 +243,7 @@ func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *d
currentInfo.SwapMemoryUsedPercent = swapInfo.UsedPercent
currentInfo.DiskData = loadDiskInfo()
currentInfo.GPUData = loadGPUInfo()
currentInfo.XPUData = loadXpuInfo()
currentInfo.GPUData, currentInfo.NPUData, currentInfo.XPUData = loadAcceleratorInfo()
if ioOption == "all" {
diskInfo, _ := disk.IOCounters()
@@ -569,32 +567,64 @@ func loadDiskInfo() []dto.DiskInfo {
return datas
}
func loadGPUInfo() []dto.GPUInfo {
ok, client := gpu.New()
var list []interface{}
if ok {
info, err := client.LoadGpuInfo()
if err != nil || len(info.GPUs) == 0 {
return nil
}
for _, item := range info.GPUs {
list = append(list, item)
func loadAcceleratorInfo() ([]dto.GPUInfo, []dto.NPUInfo, []dto.XPUInfo) {
ok, client := accelerator.New()
if !ok {
return nil, nil, nil
}
snapshot, err := client.Collect(context.Background())
if err != nil || len(snapshot.Devices) == 0 {
return nil, nil, nil
}
if warning := snapshot.Warning(); warning != nil {
global.LOG.Warnf("load accelerator dashboard data partially failed, err: %v", warning)
}
var (
gpuData []dto.GPUInfo
npuData []dto.NPUInfo
xpuData []dto.XPUInfo
)
for _, device := range snapshot.Devices {
switch device.Kind {
case accelerator.KindGPU:
if device.GPU == nil {
continue
}
var dataItem dto.GPUInfo
if err := copier.Copy(&dataItem, device.GPU); err != nil {
continue
}
dataItem.PowerUsage = dataItem.PowerDraw + " / " + dataItem.MaxPowerLimit
dataItem.MemoryUsage = dataItem.MemUsed + " / " + dataItem.MemTotal
gpuData = append(gpuData, dataItem)
case accelerator.KindNPU:
if device.NPU == nil {
continue
}
var dataItem dto.NPUInfo
if err := copier.Copy(&dataItem, device.NPU); err != nil {
continue
}
npuData = append(npuData, dataItem)
case accelerator.KindXPU:
if device.XPU == nil {
continue
}
xpuData = append(xpuData, dto.XPUInfo{
DeviceID: device.Index,
DeviceName: device.Name,
PciBdfAddress: device.BusID,
Memory: device.XPU.Basic.Memory,
Temperature: device.Metrics.Temperature.Display,
GPUUtil: device.Metrics.Utilization.Display,
MemoryUsed: device.Metrics.MemoryUsed.Display,
Power: device.Metrics.Power.Display,
MemoryUtil: device.Metrics.MemoryUtil.Display,
})
}
}
if len(list) == 0 {
return nil
}
var data []dto.GPUInfo
for _, gpu := range list {
var dataItem dto.GPUInfo
if err := copier.Copy(&dataItem, &gpu); err != nil {
continue
}
dataItem.PowerUsage = dataItem.PowerDraw + " / " + dataItem.MaxPowerLimit
dataItem.MemoryUsage = dataItem.MemUsed + " / " + dataItem.MemTotal
data = append(data, dataItem)
}
return data
return gpuData, npuData, xpuData
}
type AppLauncher struct {
@@ -610,32 +640,6 @@ func ArryContains(arr []string, element string) bool {
return false
}
func loadXpuInfo() []dto.XPUInfo {
var list []interface{}
ok, xpuClient := xpu.New()
if ok {
xpus, err := xpuClient.LoadDashData()
if err != nil || len(xpus) == 0 {
return nil
}
for _, item := range xpus {
list = append(list, item)
}
}
if len(list) == 0 {
return nil
}
var data []dto.XPUInfo
for _, gpu := range list {
var dataItem dto.XPUInfo
if err := copier.Copy(&dataItem, &gpu); err != nil {
continue
}
data = append(data, dataItem)
}
return data
}
func loadOutboundIP() string {
conn, err := network.Dial("udp", "8.8.8.8:80")
+47 -27
View File
@@ -61,18 +61,21 @@ func parseDevice(dev LsblkDevice) []response.DiskBasicInfo {
var used, avail, totalSize string
var usePercent int
isMounted := mountPoint != ""
isMounted := mountPoint != "" && mountPoint != "-"
isSystem := false
if dev.Fstype == "LVM2_member" && len(dev.Children) > 0 {
for _, child := range dev.Children {
if child.Type == "lvm" && child.Mountpoint != "" {
devicePath := "/dev/mapper/" + child.Name
totalSize, used, avail, usePercent, _ := getDiskUsageInfo(devicePath)
if child.Type == "lvm" && child.Mountpoint != "" && child.Mountpoint != "-" {
totalSize, used, avail, usePercent, _ := getDiskUsageInfo(child.Mountpoint)
childSize := child.Size
if totalSize != "" {
childSize = totalSize
}
childInfo := response.DiskBasicInfo{
Device: dev.Name,
Size: totalSize,
Size: childSize,
Model: dev.Model,
DiskType: diskType,
Filesystem: child.Fstype,
@@ -91,8 +94,7 @@ func parseDevice(dev LsblkDevice) []response.DiskBasicInfo {
return list
} else if isMounted {
isSystem = isSystemDisk(mountPoint)
devicePath := "/dev/" + dev.Name
totalSize, used, avail, usePercent, _ = getDiskUsageInfo(devicePath)
totalSize, used, avail, usePercent, _ = getDiskUsageInfo(mountPoint)
if totalSize != "" {
size = totalSize
}
@@ -229,9 +231,14 @@ func parseLsblkOutput(output string) ([]response.DiskBasicInfo, error) {
size := fields["SIZE"]
if diskType == "lvm" {
total, used, avail, usePercent, _ := getDiskUsageInfo("/dev/mapper/" + name)
if total != "" && fsType != "" {
size = total
var total, used, avail string
var usePercent int
isMounted := mountPoint != "" && mountPoint != "-"
if isMounted {
total, used, avail, usePercent, _ = getDiskUsageInfo(mountPoint)
if total != "" && fsType != "" {
size = total
}
}
lvmInfo := response.DiskBasicInfo{
@@ -246,7 +253,7 @@ func parseLsblkOutput(output string) ([]response.DiskBasicInfo, error) {
Avail: avail,
UsePercent: usePercent,
MountPoint: mountPoint,
IsMounted: mountPoint != "" && mountPoint != "-",
IsMounted: isMounted,
Serial: fields["SERIAL"],
}
lvmMap[name] = lvmInfo
@@ -269,8 +276,8 @@ func parseLsblkOutput(output string) ([]response.DiskBasicInfo, error) {
used, avail, totalSize string
usePercent int
)
if mountPoint != "" {
totalSize, used, avail, usePercent, _ = getDiskUsageInfo("/dev/" + name)
if mountPoint != "" && mountPoint != "-" {
totalSize, used, avail, usePercent, _ = getDiskUsageInfo(mountPoint)
if totalSize != "" {
size = totalSize
}
@@ -387,26 +394,39 @@ func getParentDevice(device string) string {
return device
}
func getDiskUsageInfo(device string) (size, used, avail string, usePercent int, err error) {
output, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("df", "-h", device)
func getDiskUsageInfo(mountPoint string) (size, used, avail string, usePercent int, err error) {
// Query by mount point instead of a reconstructed device path. The mount table may record
// a different device alias such as /dev/root.
output, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("df", "-h", "-P", mountPoint)
if err != nil {
return "", "", "", 0, nil
}
return parseDiskUsageOutput(output)
}
func parseDiskUsageOutput(output string) (size, used, avail string, usePercent int, err error) {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) > 1 {
output = lines[len(lines)-1]
for i := len(lines) - 1; i >= 0; i-- {
fields := strings.Fields(lines[i])
for index, field := range fields {
if index < 3 || !strings.HasSuffix(field, "%") {
continue
}
percent, parseErr := strconv.Atoi(strings.TrimSuffix(field, "%"))
if parseErr != nil {
continue
}
return fields[index-3], fields[index-2], fields[index-1], percent, nil
}
for index, field := range fields {
if index < 3 || index+1 >= len(fields) || field != "-" || !strings.HasPrefix(fields[index+1], "/") {
continue
}
return fields[index-3], fields[index-2], fields[index-1], 0, nil
}
}
fields := strings.Fields(output)
if len(fields) >= 5 {
size = fields[1]
used = fields[2]
avail = fields[3]
usePercentStr := strings.TrimSuffix(fields[4], "%")
usePercent, _ = strconv.Atoi(usePercentStr)
}
return size, used, avail, usePercent, nil
return "", "", "", 0, nil
}
func formatDisk(req dto.DiskFormatRequest) error {
+118 -1
View File
@@ -2,6 +2,7 @@ package service
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
@@ -14,14 +15,19 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/controller"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/docker_guard"
)
const dockerNftablesMinVersion = "29.0.0"
type DockerService struct{}
type IDockerService interface {
UpdateConf(req dto.SettingUpdate, withRestart bool) error
UpdateFirewallBackend(backend string) error
UpdateLogOption(req dto.LogOption) error
UpdateIpv6Option(req dto.Ipv6Option) error
UpdateConfByFile(info dto.DaemonJsonUpdateByFile) error
@@ -30,6 +36,115 @@ type IDockerService interface {
OperateDocker(req dto.DockerOperation) error
}
func loadDockerEngineVersion(ctx context.Context) string {
client, err := docker.NewDockerClient()
if err == nil {
defer client.Close()
if version, versionErr := client.ServerVersion(ctx); versionErr == nil && version.Version != "" {
return version.Version
}
}
if !cmd.Which("dockerd") {
return ""
}
stdout, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("dockerd", "--version")
if err != nil {
return ""
}
return strings.TrimSpace(stdout)
}
func dockerNftablesSupported(version string) bool {
return version != "" && common.CompareAppVersion(version, dockerNftablesMinVersion)
}
func applyDockerFirewallBackendConfig(daemonMap map[string]interface{}, backend, version string) error {
switch backend {
case constant.FirewallProviderNftables:
if !dockerNftablesSupported(version) {
return fmt.Errorf("Docker Engine %s or later is required for the nftables firewall backend", dockerNftablesMinVersion)
}
daemonMap["experimental"] = true
daemonMap["firewall-backend"] = constant.FirewallProviderNftables
case constant.FirewallProviderIptables:
if dockerNftablesSupported(version) {
daemonMap["firewall-backend"] = constant.FirewallProviderIptables
} else {
delete(daemonMap, "firewall-backend")
}
default:
return fmt.Errorf("unsupported Docker firewall backend %q", backend)
}
return nil
}
func (u *DockerService) UpdateFirewallBackend(backend string) error {
version := loadDockerEngineVersion(context.Background())
if backend == constant.FirewallProviderNftables && !dockerNftablesSupported(version) {
return fmt.Errorf("Docker Engine %s or later is required for the nftables firewall backend", dockerNftablesMinVersion)
}
if backend == constant.FirewallProviderNftables {
if err := docker_guard.CheckIPv4Forwarding(); err != nil {
return err
}
}
original, readErr := os.ReadFile(constant.DaemonJsonPath)
existed := readErr == nil
if readErr != nil && !os.IsNotExist(readErr) {
return readErr
}
daemonMap := make(map[string]interface{})
if len(bytes.TrimSpace(original)) > 0 {
if err := json.Unmarshal(original, &daemonMap); err != nil {
return fmt.Errorf("failed to parse Docker configuration: %w", err)
}
}
if err := applyDockerFirewallBackendConfig(daemonMap, backend, version); err != nil {
return err
}
updated, err := json.MarshalIndent(daemonMap, "", "\t")
if err != nil {
return err
}
if existed && bytes.Equal(bytes.TrimSpace(original), bytes.TrimSpace(updated)) {
return nil
}
if err := os.MkdirAll(path.Dir(constant.DaemonJsonPath), 0755); err != nil {
return err
}
if err := os.WriteFile(constant.DaemonJsonPath, updated, 0640); err != nil {
return err
}
restore := func() error {
if existed {
return os.WriteFile(constant.DaemonJsonPath, original, 0640)
}
err := os.Remove(constant.DaemonJsonPath)
if os.IsNotExist(err) {
return nil
}
return err
}
if err := validateDockerConfig(); err != nil {
if restoreErr := restore(); restoreErr != nil {
return fmt.Errorf("%v; failed to restore Docker configuration: %w", err, restoreErr)
}
return err
}
if err := controller.HandleRestart("docker"); err != nil {
cause := fmt.Errorf("failed to restart Docker: %w", err)
if restoreErr := restore(); restoreErr != nil {
return fmt.Errorf("%v; failed to restore Docker configuration: %w", cause, restoreErr)
}
if restoreRestartErr := controller.HandleRestart("docker"); restoreRestartErr != nil {
return fmt.Errorf("%v; the previous configuration was restored but Docker could not be restarted: %w", cause, restoreRestartErr)
}
return cause
}
return nil
}
func NewIDockerService() IDockerService {
return &DockerService{}
}
@@ -167,7 +282,9 @@ func (u *DockerService) UpdateConf(req dto.SettingUpdate, withRestart bool) erro
delete(daemonMap, "ipv6")
delete(daemonMap, "fixed-cidr-v6")
delete(daemonMap, "ip6tables")
delete(daemonMap, "experimental")
if configuredDockerFirewallBackend() != constant.FirewallProviderNftables {
delete(daemonMap, "experimental")
}
}
case "LogOption":
if req.Value == "disable" {
+1 -2
View File
@@ -9,7 +9,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/firewall"
"github.com/1Panel-dev/1Panel/agent/utils/toolbox"
)
@@ -109,7 +108,7 @@ func (u *Fail2BanService) UpdateConf(req dto.Fail2BanUpdate) error {
if req.Value == "firewallcmd-ipset" {
itemName = "firewalld"
}
client, err := firewall.NewFirewallClient()
client, err := NewSelectedSystemFirewallClient()
if err != nil {
return err
}
+73 -15
View File
@@ -52,6 +52,8 @@ type FileService struct {
const fileHistorySnapshotMaxSize = 10 * 1024 * 1024
var fileTransferLocks = newFileTransferLocks()
type IFileService interface {
GetFileList(op request.FileOption) (response.FileInfo, error)
SearchUploadWithPage(req request.SearchUploadWithPage) (int64, interface{}, error)
@@ -72,6 +74,7 @@ type IFileService interface {
ChangeName(req request.FileRename) error
Wget(w request.FileWget) (string, error)
MvFile(m request.FileMove) error
StopMvFile(taskID string) error
ChangeOwner(req request.FileRoleUpdate) error
ChangeMode(op request.FileCreate) error
BatchChangeModeAndOwner(op request.FileRoleReq) error
@@ -156,6 +159,10 @@ func (f *FileService) SearchUploadWithPage(req request.SearchUploadWithPage) (in
})
}
sort.SliceStable(files, func(i, j int) bool {
return files[i].CreatedAt > files[j].CreatedAt
})
total, start, end := len(files), (req.Page-1)*req.PageSize, req.Page*req.PageSize
if start > total {
backData = make([]response.UploadInfo, 0)
@@ -889,6 +896,7 @@ func (f *FileService) Wget(w request.FileWget) (string, error) {
key := "file-wget-" + common.GetUuid()
options := files.DownloadOptions{
IgnoreCertificate: w.IgnoreCertificate,
UseServerFilename: w.UseServerFilename,
}
if w.UseProxy {
systemProxy, err := NewISettingService().GetSystemProxy()
@@ -908,17 +916,62 @@ func (f *FileService) Wget(w request.FileWget) (string, error) {
func (f *FileService) MvFile(m request.FileMove) error {
fo := files.NewFileOp()
if err := validateFileMove(fo, m); err != nil {
return err
}
if m.TaskID == "" {
m.TaskID = common.GetUuid()
}
if !fileTransferLocks.Acquire(m.TaskID, getFileTransferPaths(m)) {
return buserr.New("TaskIsExecuting")
}
taskItem, err := task.NewTask(m.NewPath, task.TaskExec, task.TaskScopeTask, m.TaskID, 1)
if err != nil {
fileTransferLocks.Release(m.TaskID)
return err
}
go func() {
defer fileTransferLocks.Release(m.TaskID)
taskItem.AddSubTaskWithOps(m.NewPath, func(t *task.Task) error {
t.LogStart(m.NewPath)
err := f.moveFileWithContext(t.TaskCtx, m)
if err != nil && t.TaskCtx.Err() != nil {
return t.TaskCtx.Err()
}
return err
}, nil, 0, 0)
_ = taskItem.Execute()
}()
return nil
}
func (f *FileService) StopMvFile(taskID string) error {
if cancel, ok := global.LoadTaskCancel(taskID); ok {
cancel()
return nil
}
return buserr.New("TaskNotFound")
}
func validateFileMove(fo files.FileOp, m request.FileMove) error {
if !fo.Stat(m.NewPath) {
return buserr.New("ErrPathNotFound")
}
for _, oldPath := range m.OldPaths {
for _, oldPath := range append(append([]string{}, m.OldPaths...), m.CoverPaths...) {
if !fo.Stat(oldPath) {
return buserr.WithName("ErrFileNotFound", oldPath)
}
if oldPath == m.NewPath || strings.Contains(m.NewPath, filepath.Clean(oldPath)+"/") {
oldPath = filepath.Clean(oldPath)
newPath := filepath.Clean(m.NewPath)
if oldPath == newPath || strings.HasPrefix(newPath, oldPath+string(filepath.Separator)) {
return buserr.New("ErrMovePathFailed")
}
}
return nil
}
func (f *FileService) moveFileWithContext(ctx context.Context, m request.FileMove) error {
fo := files.NewFileOp()
type moveSnapshot struct {
path string
content []byte
@@ -934,13 +987,25 @@ func (f *FileService) MvFile(m request.FileMove) error {
}
if len(m.CoverPaths) > 0 {
for _, src := range m.CoverPaths {
if err := fo.CopyAndReName(src, m.NewPath, "", true); err != nil {
if err := ctx.Err(); err != nil {
return err
}
if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, "", true); err != nil {
errs = append(errs, err)
global.LOG.Errorf("cut copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error())
continue
}
if err := ctx.Err(); err != nil {
return err
}
if err := fo.DeleteDir(src); err != nil {
removeErr := fmt.Errorf("remove merged source [%s] failed: %w", src, err)
errs = append(errs, removeErr)
global.LOG.Errorf("%s", removeErr.Error())
}
}
}
if err := fo.Cut(m.OldPaths, m.NewPath, m.Name, m.Cover); err != nil {
if err := fo.CutWithContext(ctx, m.OldPaths, m.NewPath, m.Name, m.Cover); err != nil {
return err
}
for _, snapshot := range snapshots {
@@ -951,18 +1016,18 @@ func (f *FileService) MvFile(m request.FileMove) error {
}
}
}
return nil
return aggregateFileMoveErrors(errs)
}
if m.Type == "copy" {
for _, src := range m.OldPaths {
if err := fo.CopyAndReName(src, m.NewPath, m.Name, m.Cover); err != nil {
if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, m.Name, m.Cover); err != nil {
errs = append(errs, err)
global.LOG.Errorf("copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error())
}
}
if len(m.CoverPaths) > 0 {
for _, src := range m.CoverPaths {
if err := fo.CopyAndReName(src, m.NewPath, "", true); err != nil {
if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, "", true); err != nil {
errs = append(errs, err)
global.LOG.Errorf("copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error())
}
@@ -970,14 +1035,7 @@ func (f *FileService) MvFile(m request.FileMove) error {
}
}
var errString string
for _, err := range errs {
errString += err.Error() + "\n"
}
if errString != "" {
return errors.New(errString)
}
return nil
return aggregateFileMoveErrors(errs)
}
func readEditableFileHistoryContent(filePath string) ([]byte, os.FileMode, bool) {
+77
View File
@@ -0,0 +1,77 @@
package service
import (
"errors"
"path/filepath"
"strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
)
type fileTransferLockSet struct {
mu sync.Mutex
paths map[string][]string
}
func newFileTransferLocks() *fileTransferLockSet {
return &fileTransferLockSet{paths: make(map[string][]string)}
}
func (s *fileTransferLockSet) Acquire(taskID string, transferPaths []string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, activePaths := range s.paths {
for _, activePath := range activePaths {
for _, transferPath := range transferPaths {
if fileTransferPathsOverlap(activePath, transferPath) {
return false
}
}
}
}
s.paths[taskID] = transferPaths
return true
}
func (s *fileTransferLockSet) Release(taskID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.paths, taskID)
}
func getFileTransferPaths(req request.FileMove) []string {
paths := make([]string, 0, 1+len(req.OldPaths)+len(req.CoverPaths))
paths = append(paths, req.NewPath)
paths = append(paths, req.OldPaths...)
paths = append(paths, req.CoverPaths...)
unique := make(map[string]struct{}, len(paths))
result := make([]string, 0, len(paths))
for _, item := range paths {
item = filepath.Clean(item)
if _, ok := unique[item]; ok {
continue
}
unique[item] = struct{}{}
result = append(result, item)
}
return result
}
func fileTransferPathsOverlap(first, second string) bool {
return first == second || strings.HasPrefix(first, second+string(filepath.Separator)) || strings.HasPrefix(second, first+string(filepath.Separator))
}
func aggregateFileMoveErrors(errs []error) error {
if len(errs) == 0 {
return nil
}
var errString strings.Builder
for _, err := range errs {
errString.WriteString(err.Error())
errString.WriteByte('\n')
}
return errors.New(errString.String())
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,78 +0,0 @@
package service
import (
"fmt"
"os"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/controller"
)
const fail2BanRestoreWithFirewallMarker = "/run/1panel_fail2ban_restore_with_firewall"
type firewallFail2BanState struct {
markerPath string
isExist func(string) bool
isActive func(string) bool
start func(string) error
}
func newFirewallFail2BanState() *firewallFail2BanState {
return &firewallFail2BanState{
markerPath: fail2BanRestoreWithFirewallMarker,
isExist: func(serviceName string) bool {
exists, err := controller.CheckExist(serviceName)
if err != nil {
global.LOG.Warnf("check %s installation before stopping the firewall failed: %v", serviceName, err)
}
return exists
},
isActive: func(serviceName string) bool {
active, err := controller.CheckActive(serviceName)
if err != nil {
global.LOG.Warnf("check %s status before stopping the firewall failed: %v", serviceName, err)
}
return active
},
start: controller.HandleStart,
}
}
func (s *firewallFail2BanState) rememberBeforeFirewallStop() error {
if !s.isExist("fail2ban.service") {
return nil
}
if !s.isActive("fail2ban.service") {
return nil
}
return s.markForRestore()
}
func (s *firewallFail2BanState) markForRestore() error {
if err := os.WriteFile(s.markerPath, nil, 0600); err != nil {
return fmt.Errorf("mark Fail2Ban for restoration with the firewall: %w", err)
}
return nil
}
func (s *firewallFail2BanState) restoreAfterFirewallStart() error {
_, err := os.Stat(s.markerPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("load Fail2Ban restore marker after starting the firewall: %w", err)
}
if err := s.start("fail2ban.service"); err != nil {
return fmt.Errorf("restore Fail2Ban after starting the firewall: %w", err)
}
return s.clearRestoreMarker()
}
func (s *firewallFail2BanState) clearRestoreMarker() error {
if err := os.Remove(s.markerPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("clear Fail2Ban firewall restore status: %w", err)
}
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package service
import (
"context"
"fmt"
"io"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
)
const (
firewallTaskHost = "FirewallTaskHost"
firewallTaskForwarding = "FirewallTaskForwarding"
firewallTaskDocker = "FirewallTaskDocker"
)
func firewallTaskName(operation, subsystem, backend string) string {
name := i18n.GetMsgByKey(subsystem)
if backend != "" {
name += " · " + backend
}
key := "FirewallRule" + operation
if operation == task.TaskExec {
key = "FirewallTaskInitialize"
}
return i18n.GetMsgWithMap(key, map[string]interface{}{"name": name})
}
func queueFirewallRuleTask(subsystem, operation string, labels []string, apply func(context.Context) error) (dto.FilterChainOperationResponse, error) {
taskItem, err := task.NewTask(firewallTaskName(operation, subsystem, ""), operation, task.TaskScopeFirewall, "", 0)
if err != nil {
return dto.FilterChainOperationResponse{}, err
}
taskItem.AddSubTaskWithOps(taskItem.Name, func(t *task.Task) error {
t.Logf("rules=%d", len(labels))
err := t.TaskCtx.Err()
if err == nil {
err = apply(t.TaskCtx)
}
succeeded, failed := 0, 0
for _, label := range labels {
if err != nil {
failed++
t.LogFailedWithErr(label, err)
} else {
succeeded++
t.LogSuccess(label)
}
}
t.Log(i18n.GetMsgWithMap("FirewallRuleOperationResult", map[string]interface{}{
"succeeded": succeeded, "failed": failed,
}))
return err
}, nil, 0, 0)
if err := repo.NewITaskRepo().Save(context.Background(), taskItem.Task); err != nil {
taskItem.LogFailedWithErr(taskItem.Name, err)
closeUnstartedFirewallTask(taskItem)
return dto.FilterChainOperationResponse{}, fmt.Errorf("save firewall rule task: %w", err)
}
go func() { _ = taskItem.Execute() }()
return dto.FilterChainOperationResponse{TaskID: taskItem.TaskID, Queued: true}, nil
}
func closeUnstartedFirewallTask(t *task.Task) {
if cancel, ok := global.LoadTaskCancel(t.TaskID); ok {
cancel()
}
global.RemoveTaskCancel(t.TaskID)
if closer, ok := t.Logger.Out.(io.Closer); ok {
_ = closer.Close()
}
}
+65
View File
@@ -0,0 +1,65 @@
package service
import (
"strings"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/lifecycle"
)
func selectedDockerFirewallBackend(fallback string) string {
selected := configuredDockerFirewallBackend()
if selected == constant.FirewallProviderIptables || selected == constant.FirewallProviderNftables {
return selected
}
fallback = strings.ToLower(strings.TrimSpace(fallback))
if fallback == constant.FirewallProviderNftables {
return fallback
}
return constant.FirewallProviderIptables
}
func configuredDockerFirewallBackend() string {
if global.DB == nil {
return ""
}
selected, _ := settingRepo.GetValueByKey(constant.FirewallDockerBackendKey)
selected = strings.ToLower(strings.TrimSpace(selected))
if selected == constant.FirewallProviderIptables || selected == constant.FirewallProviderNftables {
return selected
}
return ""
}
func selectedSystemFirewallClient() (lifecycle.Client, error) {
if provider := configuredSystemFirewallBackend(); provider != "" {
return lifecycle.NewClientFor(provider)
}
client, err := lifecycle.NewClient()
if err != nil {
return nil, err
}
_ = settingRepo.UpdateOrCreate(constant.FirewallSystemBackendKey, client.Name())
return client, nil
}
func configuredSystemFirewallBackend() string {
if global.DB == nil {
return ""
}
provider, _ := settingRepo.GetValueByKey(constant.FirewallSystemBackendKey)
return strings.TrimSpace(provider)
}
func NewSelectedSystemFirewallClient() (lifecycle.Client, error) {
return selectedSystemFirewallClient()
}
func selectedSystemFirewallProvider() (string, error) {
client, err := selectedSystemFirewallClient()
if err != nil {
return "", err
}
return client.Name(), nil
}
+594 -132
View File
@@ -1,175 +1,637 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"os"
"reflect"
"slices"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/firewall"
fireClient "github.com/1Panel-dev/1Panel/agent/utils/firewall/client"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/client/iptables"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/docker_guard"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
filterruntime "github.com/1Panel-dev/1Panel/agent/utils/firewall/filter/runtime"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/iptables_helper"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/lifecycle"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/nftables_helper"
"gorm.io/gorm"
)
type firewallPortWhitelist struct {
Port string
Protocol string
type IFirewallSettingService interface {
CreatePortWhitelist(context.Context, dto.FirewallPortWhitelistCreate) error
UpdatePortWhitelist(context.Context, dto.FirewallPortWhitelistUpdate) error
DeletePortWhitelist(context.Context, dto.FirewallPortWhitelistDelete) error
Load(context.Context) (dto.FirewallSettings, error)
Operate(context.Context, dto.FirewallBackendOperation) error
}
func loadConfiguredFirewallPortWhiteList() ([]firewallPortWhitelist, error) {
value, err := settingRepo.GetValueByKey(constant.FirewallPortWhiteList)
if err != nil {
value = constant.FirewallPortWhiteListValue
if err := settingRepo.UpdateOrCreate(constant.FirewallPortWhiteList, value); err != nil {
type FirewallSettingService struct{}
var firewallWhitelistMu sync.Mutex
var ErrFirewallBackendCleanupRequired = errors.New("firewall backend cleanup required")
func firewallBackendCleanupRequired(current, target string) error {
return fmt.Errorf(
"%w: current backend %s still contains 1Panel runtime rules; clean it up before switching to %s",
ErrFirewallBackendCleanupRequired,
current,
target,
)
}
func NewIFirewallSettingService() IFirewallSettingService {
return &FirewallSettingService{}
}
func (s *FirewallSettingService) CreatePortWhitelist(ctx context.Context, request dto.FirewallPortWhitelistCreate) error {
return savePortWhitelist(ctx, func(current []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) {
return append(current, request.Rule), nil
})
}
func (s *FirewallSettingService) UpdatePortWhitelist(ctx context.Context, request dto.FirewallPortWhitelistUpdate) error {
return savePortWhitelist(ctx, func(current []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) {
index, err := findPortWhitelistRule(current, request.OldRule)
if err != nil {
return nil, err
}
}
return parseFirewallPortWhiteList(value)
}
func loadFirewallPortWhiteList() ([]firewallPortWhitelist, error) {
portWhiteList, err := loadConfiguredFirewallPortWhiteList()
if err != nil {
return nil, err
}
requiredPorts, err := loadRequiredFirewallPortWhiteList()
if err != nil {
return nil, err
}
return normalizeFirewallPortWhiteList(append(portWhiteList, requiredPorts...)), nil
}
func loadRequiredFirewallPortWhiteList() ([]firewallPortWhitelist, error) {
panelPort := LoadPanelPort()
if panelPort == "" {
return nil, fmt.Errorf("find 1panel service port failed")
}
return normalizeFirewallPortWhiteList([]firewallPortWhitelist{
{Port: panelPort, Protocol: "tcp"},
{Port: loadSSHPort(), Protocol: "tcp"},
}), nil
}
func parseFirewallPortWhiteList(value string) ([]firewallPortWhitelist, error) {
items := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == '\n' || r == ';' || r == ' '
current[index] = request.Rule
return current, nil
})
ports := make([]firewallPortWhitelist, 0, len(items))
exists := make(map[string]struct{})
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
port, protocol, ok := strings.Cut(item, "/")
if !ok {
protocol = "tcp"
}
port = strings.TrimSpace(port)
protocol = strings.ToLower(strings.TrimSpace(protocol))
if protocol != "tcp" && protocol != "udp" {
return nil, fmt.Errorf("invalid firewall port whitelist protocol: %s", item)
}
portNum, err := strconv.Atoi(port)
if err != nil || portNum < 1 || portNum > 65535 {
return nil, fmt.Errorf("invalid firewall port whitelist: %s", item)
}
key := fmt.Sprintf("%d/%s", portNum, protocol)
if _, ok := exists[key]; ok {
continue
}
exists[key] = struct{}{}
ports = append(ports, firewallPortWhitelist{Port: strconv.Itoa(portNum), Protocol: protocol})
}
return ports, nil
}
func normalizeFirewallPortWhiteList(portWhiteList []firewallPortWhitelist) []firewallPortWhitelist {
ports := make([]firewallPortWhitelist, 0, len(portWhiteList))
exists := make(map[string]struct{})
for _, item := range portWhiteList {
if item.Port == "" {
continue
}
key := fmt.Sprintf("%s/%s", item.Port, item.Protocol)
if _, ok := exists[key]; ok {
continue
}
exists[key] = struct{}{}
ports = append(ports, item)
func (s *FirewallSettingService) DeletePortWhitelist(ctx context.Context, request dto.FirewallPortWhitelistDelete) error {
if request.Rule == nil {
return fmt.Errorf("select one firewall port whitelist rule to delete")
}
return ports
return savePortWhitelist(ctx, func(current []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) {
index, err := findPortWhitelistRule(current, *request.Rule)
if err != nil {
return nil, err
}
return slices.Delete(current, index, index+1), nil
})
}
func syncFirewallPortWhiteListAfterUpdate(oldValue string) error {
client, err := firewall.NewFirewallClient()
func findPortWhitelistRule(rules []firewall.PortWhitelist, target firewall.PortWhitelist) (int, error) {
index := slices.IndexFunc(rules, func(rule firewall.PortWhitelist) bool {
return samePortWhitelistRule(rule, target)
})
if index < 0 {
return -1, fmt.Errorf("firewall port whitelist rule has changed or no longer exists; refresh and retry")
}
return index, nil
}
func samePortWhitelistRule(left, right firewall.PortWhitelist) bool {
if reflect.DeepEqual(left, right) {
return true
}
normalizedLeft, err := firewall.ValidatePortWhitelist([]firewall.PortWhitelist{left})
if err != nil {
return err
return false
}
if client.Name() == "iptables" {
isInit, _ := iptables.LoadInitStatus("iptables", "base")
if !isInit {
return nil
}
oldPortWhiteList, err := parseFirewallPortWhiteList(oldValue)
normalizedRight, err := firewall.ValidatePortWhitelist([]firewall.PortWhitelist{right})
if err != nil {
return false
}
slices.Sort(normalizedLeft[0].Sources)
slices.Sort(normalizedRight[0].Sources)
return reflect.DeepEqual(normalizedLeft[0], normalizedRight[0])
}
func savePortWhitelist(ctx context.Context, change func([]firewall.PortWhitelist) ([]firewall.PortWhitelist, error)) error {
firewallWhitelistMu.Lock()
defer firewallWhitelistMu.Unlock()
firewallRuleMutationMu.Lock()
defer firewallRuleMutationMu.Unlock()
defer filterruntime.InvalidateInventory()
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
current, err := loadPortWhitelistSetting(tx)
if err != nil {
return err
}
return syncIptablesFirewallPortWhiteList(true, oldPortWhiteList)
desired, err := change(current)
if err != nil {
return err
}
desired, err = firewall.ValidatePortWhitelist(desired)
if err != nil {
return err
}
value, err := json.Marshal(desired)
if err != nil {
return err
}
return tx.Where("key = ?", constant.FirewallPortWhiteList).Assign(map[string]interface{}{"value": string(value)}).
FirstOrCreate(&model.Setting{Key: constant.FirewallPortWhiteList}).Error
})
}
func checkFirewallRuleWhitelistProtection(provider filter.Provider, record model.FirewallRule) error {
ports, err := loadFirewallPortWhiteList()
if err != nil {
return err
}
rules, err := record.RulesForProvider(provider)
if err != nil {
return err
}
for _, rule := range rules {
if filter.RuleMatchesPortWhitelist(rule, ports) {
return filter.ErrProtectedRule
}
}
return nil
}
func loadPortWhitelistSetting(db *gorm.DB) ([]firewall.PortWhitelist, error) {
var setting model.Setting
if err := db.Where("key = ?", constant.FirewallPortWhiteList).First(&setting).Error; errors.Is(err, gorm.ErrRecordNotFound) {
setting.Value = constant.FirewallPortWhiteListValue
} else if err != nil {
return nil, err
}
var rules []firewall.PortWhitelist
err := json.Unmarshal([]byte(setting.Value), &rules)
return rules, err
}
func loadSSHWhitelistPortFrom(path string) (string, error) {
directives, _, err := parseSSHConfigTree(path)
if errors.Is(err, os.ErrNotExist) {
return defaultSSHPort, nil
}
if err != nil {
return "", err
}
return loadSSHPortValues(directives)[0], nil
}
func customWhitelist(entries []firewall.PortWhitelist) []firewall.PortWhitelist {
result := make([]firewall.PortWhitelist, 0, len(entries))
for _, entry := range entries {
if entry.Type == "" {
result = append(result, entry)
}
}
return result
}
func InitializeFirewallWhitelistPorts(entries []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) {
entries = slices.Clone(entries)
var sshPort string
for i := range entries {
entry := &entries[i]
if entry.Type == "" || entry.Port != "" {
continue
}
switch entry.Type {
case firewall.PortWhitelistTypePanel:
entry.Port = LoadPanelPort()
case firewall.PortWhitelistTypeSSH:
if sshPort == "" {
var err error
sshPort, err = loadSSHWhitelistPortFrom(sshPath)
if err != nil {
return nil, err
}
}
entry.Port = sshPort
}
}
return firewall.ValidatePortWhitelist(entries)
}
func updateSystemAccessPortWhitelist(ctx context.Context, serviceType string, ports []string) error {
return savePortWhitelist(ctx, func(entries []firewall.PortWhitelist) ([]firewall.PortWhitelist, error) {
for i := range entries {
if entries[i].Type == serviceType {
if len(ports) == 0 {
return nil, fmt.Errorf("firewall whitelist %s requires a port", serviceType)
}
entries[i].Port = ports[0]
}
}
return entries, nil
})
}
func (s *FirewallSettingService) Load(ctx context.Context) (dto.FirewallSettings, error) {
result := dto.FirewallSettings{PingStatus: firewall.LoadPingStatus()}
installed := make(map[string]bool)
for _, name := range lifecycle.InstalledProviders() {
installed[name] = true
}
result.System.Selected = configuredSystemFirewallBackend()
if result.System.Selected == "" {
if client, err := lifecycle.NewClient(); err == nil {
result.System.Selected = client.Name()
}
}
result.System.Current = result.System.Selected
for _, name := range []string{
constant.FirewallProviderFirewalld,
constant.FirewallProviderUFW,
constant.FirewallProviderIptables,
constant.FirewallProviderNftables,
} {
option := dto.FirewallBackendOption{Name: name, Installed: installed[name], Supported: true}
if option.Installed && name == result.System.Selected {
client, err := lifecycle.NewClientFor(name)
if err != nil {
option.Message = err.Error()
} else if supportsManagedFilterChains(name) {
option.Initialized, option.Bound, err = loadFirewallInitStatus(name, "base")
if err != nil {
option.Message = err.Error()
}
option.IPv4 = loadSystemFirewallFamilyInfo(name, constant.FirewallFamilyIPv4)
option.IPv6 = loadSystemFirewallFamilyInfo(name, constant.FirewallFamilyIPv6)
} else if option.Active, err = client.Status(); err != nil {
option.Message = err.Error()
}
}
if name == result.System.Selected && name == constant.FirewallProviderIptables {
if commands, err := lifecycle.ResolveIptablesCommands(); err == nil {
option.Implementation = commands.IPv4
}
}
result.System.Options = append(result.System.Options, option)
}
isActive, _ := client.Status()
if !isActive {
result.Forwarding.Selected = configuredForwardingBackend()
result.Forwarding.Current = result.Forwarding.Selected
for _, name := range []string{constant.FirewallProviderIptables, constant.FirewallProviderNftables} {
option := dto.FirewallBackendOption{Name: name, Installed: installed[name], Supported: true}
if option.Installed && name == result.Forwarding.Selected {
manager, err := newForwardingManagerFor(name)
if err != nil {
option.Message = err.Error()
} else if status, err := manager.Status(); err != nil {
option.Message = err.Error()
} else {
option.Initialized, option.Bound = status.IsInit, status.IsBind
ipv4Init, ipv4Bound, ipv4Err := manager.FamilyStatus(constant.FirewallFamilyIPv4)
ipv6Init, ipv6Bound, ipv6Err := manager.FamilyStatus(constant.FirewallFamilyIPv6)
option.IPv4 = dto.FirewallBackendFamilyStatus{
Available: ipv4Err == nil, Initialized: ipv4Init, Bound: ipv4Bound,
}
option.IPv6 = dto.FirewallBackendFamilyStatus{
Available: ipv6Err == nil, Initialized: ipv6Init, Bound: ipv6Bound,
}
if name == constant.FirewallProviderIptables {
if commands, commandErr := lifecycle.ResolveIptablesCommands(); commandErr == nil {
option.IPv6.Available = option.IPv6.Available && commands.IPv6Available()
if !commands.IPv6Available() {
option.IPv6.Reason = docker_guard.ReasonCommandMissing
}
}
}
}
}
if name == result.Forwarding.Selected && name == constant.FirewallProviderIptables {
if commands, err := lifecycle.ResolveIptablesCommands(); err == nil {
option.Implementation = commands.IPv4
}
}
result.Forwarding.Options = append(result.Forwarding.Options, option)
}
dockerInstalled := cmd.Which("docker")
dockerVersion := ""
if dockerInstalled {
dockerVersion = loadDockerEngineVersion(ctx)
}
result.Docker.Selected = configuredDockerFirewallBackend()
result.Docker.Current = result.Docker.Selected
for _, name := range []string{constant.FirewallProviderIptables, constant.FirewallProviderNftables} {
option := dto.FirewallBackendOption{
Name: name, Installed: installed[name], Supported: dockerInstalled,
Active: dockerInstalled && installed[name] && result.Docker.Selected == name,
}
if name == constant.FirewallProviderNftables && dockerInstalled && !dockerNftablesSupported(dockerVersion) {
option.Supported = false
option.SupportReason = "docker_version_unsupported"
option.Active = false
}
if option.Active {
guard := docker_guard.NewRuntime(name)
ipv4, ipv6 := guard.Status(docker_guard.FamilyIPv4), guard.Status(docker_guard.FamilyIPv6)
option.Initialized = ipv4.Initialized || ipv6.Initialized
option.Bound = ipv4.Bound || ipv6.Bound
option.IPv4.Initialized, option.IPv4.Bound = ipv4.Initialized, ipv4.Bound
option.IPv6.Initialized, option.IPv6.Bound = ipv6.Initialized, ipv6.Bound
option.IPv4.Available = ipv4.Reason != docker_guard.ReasonCommandMissing
option.IPv6.Available = ipv6.Reason != docker_guard.ReasonCommandMissing
option.IPv4.Reason, option.IPv6.Reason = ipv4.Reason, ipv6.Reason
}
result.Docker.Options = append(result.Docker.Options, option)
}
var err error
result.PortWhitelist, err = loadPortWhitelistSetting(global.DB.WithContext(ctx))
if err != nil {
return result, err
}
result.PanelPort = LoadPanelPort()
sshPort, sshErr := loadSSHWhitelistPortFrom(sshPath)
if sshErr != nil {
global.LOG.Warnf("load SSH port for firewall settings: %v", sshErr)
} else {
result.SSHPort = sshPort
}
return result, err
}
func loadSystemFirewallFamilyStatus(provider, family string) (bool, bool, error) {
switch provider {
case constant.FirewallProviderIptables:
return iptables_helper.LoadFamilyInitStatus(family, "base")
case constant.FirewallProviderNftables:
return nftables_helper.LoadFamilyInitStatus(filter.Family(family), "base")
default:
return false, false, fmt.Errorf("unsupported firewall provider %q", provider)
}
}
func loadSystemFirewallFamilyInfo(provider, family string) dto.FirewallBackendFamilyStatus {
if provider == constant.FirewallProviderIptables && family == constant.FirewallFamilyIPv6 {
commands, err := lifecycle.ResolveIptablesCommands()
if err != nil || !commands.IPv6Available() {
return dto.FirewallBackendFamilyStatus{Reason: docker_guard.ReasonCommandMissing}
}
}
initialized, bound, err := loadSystemFirewallFamilyStatus(provider, family)
return dto.FirewallBackendFamilyStatus{
Available: err == nil,
Initialized: initialized,
Bound: bound,
}
}
func (s *FirewallSettingService) Operate(ctx context.Context, request dto.FirewallBackendOperation) error {
if err := lockFirewallLifecycleIdle(); err != nil {
return err
}
defer firewallLifecycleTaskMu.Unlock()
if request.Subsystem != "system" && request.Backend != constant.FirewallProviderIptables && request.Backend != constant.FirewallProviderNftables {
return fmt.Errorf("%s only supports iptables or nftables", request.Subsystem)
}
if request.Subsystem == "system" && !supportsManagedFilterChains(request.Backend) && request.Operation != "select" {
return fmt.Errorf("%s does not support initialization or cleanup", request.Backend)
}
switch request.Subsystem {
case "system":
if err := s.operateSystem(request); err != nil {
return err
}
if request.Operation == "initialize" {
service := newFirewallService()
rulesErr := service.restoreStoredFirewallRules(ctx, filter.Provider(request.Backend), nil)
whitelistErr := service.SyncPortWhitelist(ctx)
return errors.Join(rulesErr, whitelistErr)
}
return nil
case "forwarding":
return s.operateForwarding(request)
case "docker":
return s.operateDocker(ctx, request)
default:
return fmt.Errorf("unsupported firewall subsystem %q", request.Subsystem)
}
}
func (s *FirewallSettingService) operateDocker(ctx context.Context, request dto.FirewallBackendOperation) error {
guard := docker_guard.NewRuntime(request.Backend)
if request.Operation == "cleanup" {
if err := guard.Cleanup(); err != nil {
return err
}
return settingRepo.UpdateOrCreate(constant.FirewallDockerPortGuardStatusKey, constant.StatusDisable)
}
previous, _ := settingRepo.GetValueByKey(constant.FirewallDockerBackendKey)
if request.Operation == "select" {
current := previous
if current == "" {
current = alternateDirectBackend(request.Backend)
}
initialized, err := dockerGuardBackendInitialized(current)
if err != nil {
return err
}
if current != request.Backend && initialized {
return firewallBackendCleanupRequired(current, request.Backend)
}
}
if err := settingRepo.UpdateOrCreate(constant.FirewallDockerBackendKey, request.Backend); err != nil {
return err
}
if request.Operation == "select" {
if err := (&DockerService{}).UpdateFirewallBackend(request.Backend); err != nil {
_ = settingRepo.UpdateOrCreate(constant.FirewallDockerBackendKey, previous)
return err
}
}
if request.Operation == "initialize" {
if err := newDockerPortGuardService().Operate(ctx, dto.DockerPortGuardOperation{Operation: "initialize"}); err != nil {
_ = settingRepo.UpdateOrCreate(constant.FirewallDockerBackendKey, previous)
return err
}
}
return nil
}
func dockerGuardBackendInitialized(backend string) (bool, error) {
guard := docker_guard.NewRuntime(backend)
for _, family := range []string{docker_guard.FamilyIPv4, docker_guard.FamilyIPv6} {
initialized, err := guard.Initialized(family)
if err != nil {
return false, err
}
if initialized {
return true, nil
}
}
return false, nil
}
func (s *FirewallSettingService) operateSystem(request dto.FirewallBackendOperation) error {
firewallRuleMutationMu.Lock()
defer firewallRuleMutationMu.Unlock()
if _, err := lifecycle.NewClientFor(request.Backend); err != nil {
return err
}
if request.Operation == "cleanup" {
return cleanupSystemBackend(request.Backend)
}
previous, _ := settingRepo.GetValueByKey(constant.FirewallSystemBackendKey)
if previous == "" {
if client, err := lifecycle.NewClient(); err == nil {
previous = client.Name()
}
}
if request.Operation == "select" && previous != "" && previous != request.Backend {
initialized, err := systemFirewallBackendInitialized(previous)
if err != nil {
return err
}
if initialized {
return firewallBackendCleanupRequired(previous, request.Backend)
}
}
if err := settingRepo.UpdateOrCreate(constant.FirewallSystemBackendKey, request.Backend); err != nil {
return err
}
rollback := func(err error) error {
if err == nil {
return nil
}
_ = settingRepo.UpdateOrCreate(constant.FirewallSystemBackendKey, previous)
return err
}
if request.Operation == "select" {
return nil
}
portWhiteList, err := loadFirewallPortWhiteList()
if err != nil {
return err
initErr := newFirewallService().operateFilterChainBaseLocked(request.Backend, dto.FilterChainOperation{
Name: constant.FirewallBasicChain, Operate: string(firewall.BaseOperationInit),
})
if initErr != nil {
return rollback(initErr)
}
oldPortWhiteList, err := parseFirewallPortWhiteList(oldValue)
if err != nil {
return err
}
requiredPorts, err := loadRequiredFirewallPortWhiteList()
if err != nil {
return err
}
oldPortWhiteList = normalizeFirewallPortWhiteList(append(oldPortWhiteList, requiredPorts...))
return syncFirewallClientPortWhiteList(client, oldPortWhiteList, portWhiteList)
return settingRepo.UpdateOrCreate(constant.FirewallFilterInitializedKey, constant.StatusEnable)
}
func syncFirewallClientPortWhiteList(client firewall.FilterClient, oldPortWhiteList, portWhiteList []firewallPortWhitelist) error {
oldPorts := firewallPortWhiteListMap(oldPortWhiteList)
newPorts := firewallPortWhiteListMap(portWhiteList)
for _, item := range oldPortWhiteList {
key := firewallPortWhiteListKey(item)
if _, ok := newPorts[key]; ok {
continue
func systemFirewallBackendInitialized(backend string) (bool, error) {
return systemFirewallBackendInitializedWithClientFactory(backend, lifecycle.NewClientFor)
}
func systemFirewallBackendInitializedWithClientFactory(
backend string,
newClient func(string) (lifecycle.Client, error),
) (bool, error) {
client, err := newClient(backend)
if err != nil {
if errors.Is(err, lifecycle.ErrNotInstalled) {
return false, nil
}
if err := client.Port(fireClient.FireInfo{Port: item.Port, Protocol: item.Protocol, Strategy: "accept"}, "remove"); err != nil {
return false, err
}
if supportsManagedFilterChains(backend) {
for _, family := range []string{constant.FirewallFamilyIPv4, constant.FirewallFamilyIPv6} {
initialized, _, err := loadSystemFirewallFamilyStatus(backend, family)
if family == constant.FirewallFamilyIPv6 && errors.Is(err, filter.ErrFamilyUnavailable) {
continue
}
if err != nil {
return false, err
}
if initialized {
return true, nil
}
}
return false, nil
}
return client.Status()
}
func cleanupSystemBackend(backend string) error {
switch backend {
case constant.FirewallProviderIptables:
return newIptablesHelperManager().Cleanup()
case constant.FirewallProviderNftables:
return newNftablesHelperManager().Cleanup()
default:
return fmt.Errorf("cleanup is only available for 1Panel-owned iptables and nftables resources")
}
}
func cleanupInactiveSystemBackend(backend string) error {
switch backend {
case constant.FirewallProviderIptables:
return (&iptables_helper.Manager{}).Cleanup()
case constant.FirewallProviderNftables:
return (&nftables_helper.Manager{}).Cleanup()
default:
return fmt.Errorf("cleanup is only available for 1Panel-owned iptables and nftables resources")
}
}
func (s *FirewallSettingService) operateForwarding(request dto.FirewallBackendOperation) error {
manager, err := newForwardingManagerFor(request.Backend)
if err != nil {
return err
}
if request.Operation == "cleanup" {
if err := manager.Cleanup(); err != nil {
return err
}
}
for _, item := range portWhiteList {
key := firewallPortWhiteListKey(item)
if _, ok := oldPorts[key]; ok {
continue
}
if err := client.Port(fireClient.FireInfo{Port: item.Port, Protocol: item.Protocol, Strategy: "accept"}, "add"); err != nil {
if err := settingRepo.UpdateOrCreate(constant.FirewallForwardingInitializedKey, constant.StatusDisable); err != nil {
return err
}
recordForwardingSyncError(nil)
return nil
}
return client.Reload()
previous, _ := settingRepo.GetValueByKey(constant.FirewallForwardingBackendKey)
if request.Operation == "select" {
current := previous
if current == "" {
detected, err := newForwardingManager()
if err != nil {
return err
}
current = detected.Name()
}
initialized, err := forwardingBackendInitialized(current)
if err != nil {
return err
}
if current != request.Backend && initialized {
return firewallBackendCleanupRequired(current, request.Backend)
}
}
if err := settingRepo.UpdateOrCreate(constant.FirewallForwardingBackendKey, request.Backend); err != nil {
return err
}
if request.Operation == "initialize" {
return newForwardingService().Enable()
}
recordForwardingSyncError(nil)
return nil
}
func firewallPortWhiteListMap(portWhiteList []firewallPortWhitelist) map[string]struct{} {
ports := make(map[string]struct{})
for _, item := range portWhiteList {
ports[firewallPortWhiteListKey(item)] = struct{}{}
func forwardingBackendInitialized(backend string) (bool, error) {
manager, err := newForwardingManagerFor(backend)
if err != nil {
if errors.Is(err, lifecycle.ErrNotInstalled) {
return false, nil
}
return false, err
}
return ports
for _, family := range []string{constant.FirewallFamilyIPv4, constant.FirewallFamilyIPv6} {
initialized, _, err := manager.FamilyStatus(family)
if err != nil {
return false, err
}
if initialized {
return true, nil
}
}
return false, nil
}
func firewallPortWhiteListKey(item firewallPortWhitelist) string {
return item.Port + "/" + item.Protocol
func alternateDirectBackend(backend string) string {
if backend == constant.FirewallProviderNftables {
return constant.FirewallProviderIptables
}
return constant.FirewallProviderNftables
}
File diff suppressed because it is too large Load Diff
+577
View File
@@ -0,0 +1,577 @@
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/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/firewall"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/lifecycle"
)
type IForwardingService interface {
LoadBaseInfo() (dto.FirewallSubsystemStatus, error)
SearchRules(request dto.ForwardRuleSearch) (int64, []dto.ForwardRule, error)
OperateRules(dto.ForwardRuleOperate) (dto.FilterChainOperationResponse, error)
Enable() error
QueueInitialization(dto.FirewallInitializationTask) (dto.FilterChainOperationResponse, error)
Restore(context.Context) error
}
type ForwardingService struct {
managerFactory func() (*forwarding.Manager, error)
rules repo.IForwardingRuleRepo
enabled func() (bool, error)
persistBackend func(string) error
markEnabled func() error
}
var errForwardingBackendUnavailable = errors.New("no supported forwarding backend detected")
var forwardingMutationMu sync.Mutex
const (
forwardingSyncConverged = "converged"
forwardingSyncMissing = "missing"
forwardingSyncRuntimeOnly = "runtime_only"
)
var (
forwardingSyncStateMu sync.RWMutex
forwardingLastSyncErr error
)
func NewIForwardingService() IForwardingService {
return newForwardingService()
}
func newForwardingService() *ForwardingService {
return &ForwardingService{
managerFactory: newForwardingManager,
rules: repo.NewIForwardingRuleRepo(),
enabled: forwardingPersistedEnabled,
markEnabled: func() error {
return settingRepo.UpdateOrCreate(constant.FirewallForwardingInitializedKey, constant.StatusEnable)
},
persistBackend: func(backend string) error {
return settingRepo.UpdateOrCreate(constant.FirewallForwardingBackendKey, backend)
},
}
}
func (s *ForwardingService) LoadBaseInfo() (dto.FirewallSubsystemStatus, error) {
selected := configuredForwardingBackend()
baseInfo := dto.FirewallSubsystemStatus{
Version: "-", Name: forwardingDisplayName(selected), Backend: selected, SyncError: lastForwardingSyncError(),
}
manager, err := s.managerFactory()
if err != nil {
if errors.Is(err, errForwardingBackendUnavailable) {
baseInfo.Reason = constant.FirewallBackendNotInstalled
return baseInfo, nil
}
return baseInfo, err
}
status, err := manager.Status()
if err != nil {
return baseInfo, err
}
baseInfo.IsExist = true
baseInfo.Name, baseInfo.Backend = forwardingDisplayName(status.Name), status.Name
baseInfo.Version = status.Version
baseInfo.PingStatus = firewall.LoadPingStatus()
baseInfo.IsInit, baseInfo.IsBind = status.IsInit, status.IsBind
baseInfo.IPv4 = loadForwardingFamilyInfo(manager, status.Name, constant.FirewallFamilyIPv4)
baseInfo.IPv6 = loadForwardingFamilyInfo(manager, status.Name, constant.FirewallFamilyIPv6)
return baseInfo, nil
}
func loadForwardingFamilyInfo(manager *forwarding.Manager, backend, family string) dto.FirewallBackendFamilyStatus {
initialized, bound, err := manager.FamilyStatus(family)
available := err == nil
if backend == constant.FirewallProviderIptables && family == constant.FirewallFamilyIPv6 {
commands, commandErr := lifecycle.ResolveIptablesCommands()
available = available && commandErr == nil && commands.IPv6Available()
}
return dto.FirewallBackendFamilyStatus{Available: available, Initialized: initialized, Bound: bound}
}
func forwardingDisplayName(backend string) string {
switch backend {
case constant.FirewallProviderIptables, constant.FirewallProviderNftables:
return backend + "-forward"
default:
return backend
}
}
func (s *ForwardingService) SearchRules(request dto.ForwardRuleSearch) (int64, []dto.ForwardRule, error) {
if request.Strategy != "" {
return 0, nil, nil
}
stored, err := s.rules.List(context.Background())
if err != nil {
return 0, nil, err
}
manager, err := s.managerFactory()
if err != nil {
return 0, nil, err
}
runtime, err := manager.List("", "")
if err != nil {
return 0, nil, err
}
inventory, err := mergeForwardingInventory(stored, runtime)
if err != nil {
return 0, nil, err
}
keyword := strings.ToLower(strings.TrimSpace(request.Info))
filtered := inventory[:0]
for _, item := range inventory {
if keyword == "" || forwardingRuleMatchesKeyword(item, keyword) {
filtered = append(filtered, item)
}
}
inventory = filtered
total := len(inventory)
start, end := (request.Page-1)*request.PageSize, request.Page*request.PageSize
if request.All {
start, end = 0, total
}
if start > total {
return int64(total), make([]dto.ForwardRule, 0), nil
}
if end > total {
end = total
}
pageRules := inventory[start:end]
var items []dto.ForwardRule
if pageRules != nil {
items = make([]dto.ForwardRule, 0, len(pageRules))
}
for index, item := range pageRules {
items = append(items, dto.ForwardRule{
ID: item.ID,
Num: strconv.Itoa(start + index + 1),
Family: item.Rule.Family,
Protocol: item.Rule.Protocol,
Port: item.Rule.Port,
TargetIP: item.Rule.TargetIP,
TargetPort: item.Rule.TargetPort,
Interface: item.Rule.Interface,
IsDesired: item.IsDesired,
IsRuntime: item.IsRuntime,
SyncStatus: item.SyncStatus(),
})
}
return int64(total), items, nil
}
func forwardingRuleMatchesKeyword(item forwardingInventoryItem, keyword string) bool {
values := []string{
item.Rule.Family, item.Rule.Protocol, item.Rule.Port, item.Rule.TargetIP,
item.Rule.TargetPort, item.Rule.Interface, item.SyncStatus(),
}
for _, value := range values {
if strings.Contains(strings.ToLower(value), keyword) {
return true
}
}
return false
}
func (s *ForwardingService) OperateRules(request dto.ForwardRuleOperate) (dto.FilterChainOperationResponse, error) {
labels := make([]string, len(request.Rules))
operation := task.TaskCreate
for i, rule := range request.Rules {
labels[i] = fmt.Sprintf("[%d/%d] %s %s %s %s -> %s:%s", i+1, len(request.Rules), rule.Operation, rule.Family, rule.Protocol, rule.Port, rule.TargetIP, rule.TargetPort)
if rule.Operation != "add" {
operation = task.TaskUpdate
}
}
if forwardingOperationsOnlyRemove(request.Rules) {
operation = task.TaskDelete
}
return queueFirewallRuleTask(firewallTaskForwarding, operation, labels, func(ctx context.Context) error {
return s.operateRules(ctx, request)
})
}
func (s *ForwardingService) operateRules(ctx context.Context, request dto.ForwardRuleOperate) error {
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
stored, err := s.rules.List(ctx)
if err != nil {
return err
}
desired, err := applyForwardingOperations(forwardingRulesFromModels(stored), request.Rules)
if errors.Is(err, forwarding.ErrRuleExists) {
return buserr.New("ErrRecordExist")
} else if err != nil {
return err
}
if err := s.rules.ReplaceAll(ctx, forwardingRuleModels(desired)); err != nil {
return err
}
if err := s.reconcile(desired); err != nil {
recordForwardingSyncError(err)
if request.ForceDelete && forwardingOperationsOnlyRemove(request.Rules) {
if global.LOG != nil {
global.LOG.Error(err)
}
return nil
}
return err
}
recordForwardingSyncError(nil)
return nil
}
func (s *ForwardingService) Enable() error {
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
manager, err := s.managerFactory()
if err != nil {
recordForwardingSyncError(err)
return err
}
if err := s.persistForwardingEnabled(); err != nil {
recordForwardingSyncError(err)
return err
}
if err := s.activateManager(manager); err != nil {
recordForwardingSyncError(err)
return err
}
rules, err := s.rules.List(context.Background())
if err != nil {
recordForwardingSyncError(err)
return err
}
err = manager.Reconcile(forwardingRulesFromModels(rules))
recordForwardingSyncError(err)
return err
}
func (s *ForwardingService) QueueInitialization(
request dto.FirewallInitializationTask,
) (dto.FilterChainOperationResponse, error) {
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeFirewall, 0); err != nil {
return dto.FilterChainOperationResponse{}, err
}
taskItem, err := task.NewTask(firewallTaskName(task.TaskExec, firewallTaskForwarding, ""), task.TaskExec, task.TaskScopeFirewall, request.TaskID, 0)
if err != nil {
return dto.FilterChainOperationResponse{}, fmt.Errorf("create forwarding initialization task: %w", err)
}
var manager *forwarding.Manager
var backend string
taskItem.AddSubTask(i18n.GetMsgByKey("FirewallEnableForwardingStep"), func(t *task.Task) error {
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
var err error
manager, err = s.managerFactory()
if err != nil {
recordForwardingSyncError(err)
return err
}
backend = manager.Name()
t.Logf("backend=%s", backend)
if err := s.persistForwardingEnabled(); err != nil {
recordForwardingSyncError(err)
return err
}
if err := s.activateManager(manager); err != nil {
recordForwardingSyncError(err)
return err
}
return nil
}, nil)
taskItem.AddSubTask(i18n.GetMsgByKey("FirewallRestoreForwardingRulesStep"), func(t *task.Task) error {
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
rules, err := s.rules.List(t.TaskCtx)
if err != nil {
recordForwardingSyncError(err)
return err
}
err = manager.Reconcile(forwardingRulesFromModels(rules))
recordForwardingSyncError(err)
return err
}, nil)
if err := repo.NewITaskRepo().Save(context.Background(), taskItem.Task); err != nil {
return dto.FilterChainOperationResponse{}, fmt.Errorf("save forwarding initialization task: %w", err)
}
go func() { _ = taskItem.Execute() }()
return dto.FilterChainOperationResponse{TaskID: taskItem.TaskID, Queued: true}, nil
}
func (s *ForwardingService) Restore(ctx context.Context) error {
forwardingMutationMu.Lock()
defer forwardingMutationMu.Unlock()
enabled, err := s.forwardingEnabled()
if err != nil || !enabled {
if err != nil {
recordForwardingSyncError(err)
}
return err
}
manager, err := s.managerFactory()
if err != nil {
recordForwardingSyncError(err)
return err
}
stored, err := s.rules.List(ctx)
if err != nil {
recordForwardingSyncError(err)
return err
}
if err := s.activateManager(manager); err != nil {
recordForwardingSyncError(err)
return err
}
err = manager.Reconcile(forwardingRulesFromModels(stored))
recordForwardingSyncError(err)
return err
}
func (s *ForwardingService) reconcile(rules []forwarding.Rule) error {
manager, err := s.managerFactory()
if err != nil {
return err
}
return s.reconcileWithManager(manager, rules)
}
func (s *ForwardingService) reconcileWithManager(manager *forwarding.Manager, rules []forwarding.Rule) error {
enabled, err := s.forwardingEnabled()
if err != nil || !enabled {
return err
}
if err := s.activateManager(manager); err != nil {
return err
}
return manager.Reconcile(rules)
}
func (s *ForwardingService) activateManager(manager *forwarding.Manager) error {
if err := s.saveForwardingBackend(manager.Name()); err != nil {
return err
}
return manager.Enable()
}
func (s *ForwardingService) forwardingEnabled() (bool, error) {
if s.enabled != nil {
return s.enabled()
}
return forwardingPersistedEnabled()
}
func (s *ForwardingService) saveForwardingBackend(backend string) error {
if s.persistBackend != nil {
return s.persistBackend(backend)
}
return settingRepo.UpdateOrCreate(constant.FirewallForwardingBackendKey, backend)
}
func (s *ForwardingService) persistForwardingEnabled() error {
if s.markEnabled != nil {
return s.markEnabled()
}
return settingRepo.UpdateOrCreate(constant.FirewallForwardingInitializedKey, constant.StatusEnable)
}
func forwardingPersistedEnabled() (bool, error) {
status, err := settingRepo.GetValueByKey(constant.FirewallForwardingInitializedKey)
return status == constant.StatusEnable, err
}
func forwardingRulesFromModels(stored []model.ForwardingRule) []forwarding.Rule {
rules := make([]forwarding.Rule, 0, len(stored))
for _, rule := range stored {
rules = append(rules, forwarding.Rule{
Family: rule.Family, Protocol: rule.Protocol, Port: rule.Port, TargetIP: rule.TargetIP,
TargetPort: rule.TargetPort, Interface: rule.Interface,
})
}
return rules
}
func forwardingRuleModels(rules []forwarding.Rule) []model.ForwardingRule {
stored := make([]model.ForwardingRule, 0, len(rules))
for _, rule := range rules {
stored = append(stored, model.ForwardingRule{
Family: rule.Family, Protocol: rule.Protocol, Port: rule.Port, TargetIP: rule.TargetIP,
TargetPort: rule.TargetPort, Interface: rule.Interface,
})
}
return stored
}
type forwardingInventoryItem struct {
ID uint
Rule forwarding.Rule
IsDesired bool
IsRuntime bool
}
func (i forwardingInventoryItem) SyncStatus() string {
switch {
case i.IsDesired && i.IsRuntime:
return forwardingSyncConverged
case i.IsDesired:
return forwardingSyncMissing
default:
return forwardingSyncRuntimeOnly
}
}
func mergeForwardingInventory(
stored []model.ForwardingRule,
runtime []forwarding.Rule,
) ([]forwardingInventoryItem, error) {
items := make([]forwardingInventoryItem, 0, len(stored)+len(runtime))
byIdentity := make(map[string]int, len(stored)+len(runtime))
for _, record := range stored {
rule, err := forwarding.NormalizeRule(forwarding.Rule{
Family: record.Family, Protocol: record.Protocol, Port: record.Port, TargetIP: record.TargetIP,
TargetPort: record.TargetPort, Interface: record.Interface,
})
if err != nil {
return nil, fmt.Errorf("normalize desired forwarding rule: %w", err)
}
key := rule.Identity()
byIdentity[key] = len(items)
items = append(items, forwardingInventoryItem{ID: record.ID, Rule: rule, IsDesired: true})
}
for _, observed := range runtime {
rule, err := forwarding.NormalizeRule(observed)
if err != nil {
return nil, fmt.Errorf("normalize runtime forwarding rule: %w", err)
}
key := rule.Identity()
if index, exists := byIdentity[key]; exists {
items[index].IsRuntime = true
continue
}
byIdentity[key] = len(items)
items = append(items, forwardingInventoryItem{Rule: rule, IsRuntime: true})
}
return items, nil
}
func recordForwardingSyncError(err error) {
forwardingSyncStateMu.Lock()
forwardingLastSyncErr = err
forwardingSyncStateMu.Unlock()
}
func lastForwardingSyncError() string {
forwardingSyncStateMu.RLock()
defer forwardingSyncStateMu.RUnlock()
if forwardingLastSyncErr == nil {
return ""
}
return forwardingLastSyncErr.Error()
}
func applyForwardingOperations(current []forwarding.Rule, requested []dto.ForwardRuleOperation) ([]forwarding.Rule, error) {
desired := make([]forwarding.Rule, 0, len(current)+len(requested))
for _, rule := range current {
normalized, err := forwarding.NormalizeRule(rule)
if err != nil {
return nil, fmt.Errorf("normalize persisted forwarding rule: %w", err)
}
desired = append(desired, normalized)
}
for _, operation := range requested {
for _, protocol := range strings.Split(operation.Protocol, "/") {
rule, err := forwarding.NormalizeRule(forwarding.Rule{
Family: operation.Family, Protocol: protocol, Port: operation.Port, TargetIP: operation.TargetIP,
TargetPort: operation.TargetPort, Interface: operation.Interface,
})
if err != nil {
return nil, err
}
index := forwardingRuleIndex(desired, rule)
switch forwarding.OperationType(operation.Operation) {
case forwarding.OperationAdd:
if index >= 0 {
return nil, forwarding.ErrRuleExists
}
desired = append(desired, rule)
case forwarding.OperationRemove:
if index >= 0 {
desired = append(desired[:index], desired[index+1:]...)
}
default:
return nil, fmt.Errorf("unsupported forwarding operation %q", operation.Operation)
}
}
}
return desired, nil
}
func forwardingRuleIndex(rules []forwarding.Rule, wanted forwarding.Rule) int {
wantedIdentity := wanted.Identity()
for index, rule := range rules {
if rule.Identity() == wantedIdentity {
return index
}
}
return -1
}
func forwardingOperationsOnlyRemove(operations []dto.ForwardRuleOperation) bool {
if len(operations) == 0 {
return false
}
for _, operation := range operations {
if operation.Operation != string(forwarding.OperationRemove) {
return false
}
}
return true
}
func newForwardingManager() (*forwarding.Manager, error) {
return newForwardingManagerFor(configuredForwardingBackend())
}
func configuredForwardingBackend() string {
selected, _ := settingRepo.GetValueByKey(constant.FirewallForwardingBackendKey)
selected = strings.TrimSpace(selected)
if selected == "" {
return constant.FirewallProviderIptables
}
return selected
}
func newForwardingManagerFor(backend string) (*forwarding.Manager, error) {
client, err := lifecycle.NewClientFor(backend)
if err != nil {
return nil, fmt.Errorf(
"%w: selected forwarding backend %s: %w",
errForwardingBackendUnavailable, backend, err,
)
}
adapter, err := forwarding.New(client.Name())
if err != nil {
return nil, err
}
return forwarding.NewManager(adapter, client), nil
}
-235
View File
@@ -1,235 +0,0 @@
package service
import (
"sort"
"strconv"
"strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"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/utils/firewall"
forwardClient "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
)
type IForwardingService interface {
LoadBaseInfo() (dto.FirewallBaseInfo, error)
SearchWithPage(search dto.ForwardRuleSearch) (int64, interface{}, error)
Operate(req dto.ForwardRuleOperate) error
Enable() error
Replay() error
}
type ForwardingService struct {
adapterFactory func() (forwardClient.Adapter, error)
filterFactory func() (firewall.FilterClient, error)
}
func NewIForwardingService() IForwardingService {
return &ForwardingService{
adapterFactory: newForwardingAdapter,
filterFactory: firewall.NewFirewallClient,
}
}
func newForwardingAdapter() (forwardClient.Adapter, error) {
client, err := firewall.NewFirewallClient()
if err != nil {
return nil, err
}
return forwardClient.NewAdapter(client.Name())
}
func (s *ForwardingService) LoadBaseInfo() (dto.FirewallBaseInfo, error) {
baseInfo := dto.FirewallBaseInfo{Version: "-", Name: "-"}
adapter, err := s.adapterFactory()
if err != nil {
global.LOG.Errorf("load forwarding failed, err: %v", err)
return baseInfo, nil
}
filter, err := s.filterFactory()
if err != nil {
global.LOG.Errorf("load firewall status failed, err: %v", err)
return baseInfo, nil
}
baseInfo.IsExist = true
baseInfo.Name = adapter.Name()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
baseInfo.PingStatus = firewall.LoadPingStatus()
baseInfo.Version, _ = filter.Version()
}()
go func() {
defer wg.Done()
baseInfo.IsActive, _ = filter.Status()
baseInfo.IsInit, baseInfo.IsBind = adapter.InitStatus()
}()
wg.Wait()
return baseInfo, nil
}
func (s *ForwardingService) SearchWithPage(req dto.ForwardRuleSearch) (int64, interface{}, error) {
adapter, err := s.adapterFactory()
if err != nil {
return 0, nil, err
}
rules, err := adapter.List()
if err != nil {
return 0, nil, err
}
if req.Strategy != "" {
return 0, nil, nil
}
var filtered []forwardClient.Rule
for _, rule := range rules {
if req.Info != "" && !strings.Contains(rule.Port, req.Info) &&
!strings.Contains(rule.TargetPort, req.Info) && !strings.Contains(rule.TargetIP, req.Info) {
continue
}
filtered = append(filtered, rule)
}
total := len(filtered)
start, end := (req.Page-1)*req.PageSize, req.Page*req.PageSize
if start > total {
return int64(total), make([]dto.ForwardRule, 0), nil
}
if end > total {
end = total
}
pageRules := filtered[start:end]
var items []dto.ForwardRule
if pageRules != nil {
items = make([]dto.ForwardRule, 0, len(pageRules))
}
for _, rule := range pageRules {
items = append(items, dto.ForwardRule{
Num: rule.Num,
Protocol: rule.Protocol,
Port: rule.Port,
TargetIP: rule.TargetIP,
TargetPort: rule.TargetPort,
Interface: rule.Interface,
})
}
return int64(total), items, nil
}
func (s *ForwardingService) Operate(req dto.ForwardRuleOperate) error {
adapter, err := s.adapterFactory()
if err != nil {
return err
}
rules, _ := adapter.List()
kept := rules[:0]
for _, rule := range rules {
shouldKeep := true
for i := range req.Rules {
reqRule := &req.Rules[i]
if reqRule.TargetIP == "" {
reqRule.TargetIP = "127.0.0.1"
}
if reqRule.Operation == "remove" && requestMatchesForwardRule(*reqRule, rule) {
shouldKeep = false
break
}
}
if shouldKeep {
kept = append(kept, rule)
}
}
for _, rule := range kept {
for _, reqRule := range req.Rules {
if reqRule.Operation != "remove" && requestMatchesForwardRule(reqRule, rule) {
return buserr.New("ErrRecordExist")
}
}
}
sort.SliceStable(req.Rules, func(i, j int) bool {
if req.Rules[i].Operation == "remove" && req.Rules[j].Operation != "remove" {
return true
}
if req.Rules[i].Operation != "remove" && req.Rules[j].Operation == "remove" {
return false
}
n1, _ := strconv.Atoi(req.Rules[i].Num)
n2, _ := strconv.Atoi(req.Rules[j].Num)
return n1 > n2
})
for _, rule := range req.Rules {
for _, protocol := range strings.Split(rule.Protocol, "/") {
targetIP := rule.TargetIP
if targetIP == "" {
targetIP = "127.0.0.1"
}
err := adapter.Operate(forwardClient.Rule{
Num: rule.Num,
Protocol: protocol,
Port: rule.Port,
TargetIP: targetIP,
TargetPort: rule.TargetPort,
Interface: rule.Interface,
}, rule.Operation)
if err == nil {
continue
}
if req.ForceDelete {
global.LOG.Error(err)
continue
}
return err
}
}
return nil
}
func requestMatchesForwardRule(req dto.ForwardRuleOperation, rule forwardClient.Rule) bool {
for _, protocol := range strings.Split(req.Protocol, "/") {
if req.Port == rule.Port && req.TargetPort == rule.TargetPort && req.TargetIP == rule.TargetIP &&
protocol == rule.Protocol && req.Interface == rule.Interface {
return true
}
}
return false
}
func (s *ForwardingService) Enable() error {
adapter, err := s.adapterFactory()
if err != nil {
return err
}
if err := adapter.Enable(); err != nil {
return err
}
if adapter.Name() != "firewalld" {
_ = settingRepo.Update("IptablesForwardStatus", constant.StatusEnable)
}
return nil
}
func (s *ForwardingService) Replay() error {
adapter, err := s.adapterFactory()
if err != nil {
return err
}
if err := adapter.Replay(); err != nil {
return err
}
if adapter.Name() == "firewalld" {
return nil
}
status, _ := settingRepo.GetValueByKey("IptablesForwardStatus")
if status == constant.StatusEnable {
return adapter.Enable()
}
return nil
}
@@ -1,152 +0,0 @@
package service
import (
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/firewall"
forwardClient "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
"github.com/go-playground/validator/v10"
)
type forwardingCall struct {
rule forwardClient.Rule
operation string
}
type fakeForwardingAdapter struct {
name string
rules []forwardClient.Rule
listErr error
operateErr error
calls []forwardingCall
}
func (f *fakeForwardingAdapter) Name() string { return f.name }
func (f *fakeForwardingAdapter) List() ([]forwardClient.Rule, error) {
return append([]forwardClient.Rule(nil), f.rules...), f.listErr
}
func (f *fakeForwardingAdapter) Operate(rule forwardClient.Rule, operation string) error {
f.calls = append(f.calls, forwardingCall{rule: rule, operation: operation})
return f.operateErr
}
func (f *fakeForwardingAdapter) Enable() error { return nil }
func (f *fakeForwardingAdapter) InitStatus() (bool, bool) { return true, true }
func (f *fakeForwardingAdapter) Replay() error { return nil }
func forwardingServiceWithAdapter(adapter forwardClient.Adapter) *ForwardingService {
return &ForwardingService{
adapterFactory: func() (forwardClient.Adapter, error) { return adapter, nil },
filterFactory: firewall.NewFirewallClient,
}
}
func TestForwardingAndFilterInterfacesAreSeparated(t *testing.T) {
filterType := reflect.TypeOf((*firewall.FilterClient)(nil)).Elem()
for _, method := range []string{"ListForward", "PortForward", "EnableForward"} {
if _, ok := filterType.MethodByName(method); ok {
t.Fatalf("filter interface still exposes %s", method)
}
}
firewallServiceType := reflect.TypeOf((*IFirewallService)(nil)).Elem()
if _, ok := firewallServiceType.MethodByName("OperateForwardRule"); ok {
t.Fatal("firewall service still owns forwarding writes")
}
forwardingServiceType := reflect.TypeOf((*IForwardingService)(nil)).Elem()
for _, method := range []string{"LoadBaseInfo", "SearchWithPage", "Operate", "Enable", "Replay"} {
if _, ok := forwardingServiceType.MethodByName(method); !ok {
t.Fatalf("forwarding service missing %s", method)
}
}
}
func TestForwardingInitRequestContract(t *testing.T) {
req := dto.IptablesOp{Name: "1PANEL_FORWARD", Operate: "init-forward"}
if err := validator.New().Struct(req); err != nil {
t.Fatalf("frontend forwarding initialization request must remain valid: %v", err)
}
}
func TestForwardingSearchPreservesAPIShapeAndPagination(t *testing.T) {
adapter := &fakeForwardingAdapter{name: "iptables", rules: []forwardClient.Rule{
{Num: "1", Protocol: "tcp", Port: "8080", TargetIP: "10.0.0.2", TargetPort: "80", Interface: "eth0"},
{Num: "2", Protocol: "udp", Port: "5353", TargetIP: "127.0.0.1", TargetPort: "53"},
}}
service := forwardingServiceWithAdapter(adapter)
total, value, err := service.SearchWithPage(dto.ForwardRuleSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 10}, Info: "10.0.0.2"})
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Fatalf("got total %d want 1", total)
}
items, ok := value.([]dto.ForwardRule)
if !ok || len(items) != 1 || items[0].Port != "8080" {
t.Fatalf("unexpected items: %#v", value)
}
data, err := json.Marshal(items[0])
if err != nil {
t.Fatal(err)
}
var fields map[string]interface{}
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatal(err)
}
wantFields := []string{"id", "chain", "family", "address", "port", "protocol", "strategy", "num", "targetIP", "targetPort", "interface", "usedStatus", "description"}
for _, field := range wantFields {
if _, ok := fields[field]; !ok {
t.Fatalf("forward response dropped compatibility field %q: %s", field, data)
}
}
}
func TestForwardingOperatePreservesDuplicateAndOrderingContracts(t *testing.T) {
existing := &fakeForwardingAdapter{name: "ufw", rules: []forwardClient.Rule{
{Protocol: "tcp", Port: "8080", TargetIP: "127.0.0.1", TargetPort: "80"},
}}
service := forwardingServiceWithAdapter(existing)
err := service.Operate(dto.ForwardRuleOperate{Rules: []dto.ForwardRuleOperation{{
Operation: "add", Protocol: "tcp", Port: "8080", TargetPort: "80",
}}})
if err == nil {
t.Fatal("duplicate forwarding rule must be rejected")
}
if len(existing.calls) != 0 {
t.Fatalf("duplicate check wrote forwarding state: %#v", existing.calls)
}
adapter := &fakeForwardingAdapter{name: "iptables"}
service = forwardingServiceWithAdapter(adapter)
err = service.Operate(dto.ForwardRuleOperate{Rules: []dto.ForwardRuleOperation{
{Operation: "add", Protocol: "tcp/udp", Port: "9000", TargetIP: "10.0.0.2", TargetPort: "90"},
{Operation: "remove", Num: "1", Protocol: "tcp", Port: "8001", TargetIP: "10.0.0.2", TargetPort: "81"},
{Operation: "remove", Num: "3", Protocol: "tcp", Port: "8003", TargetIP: "10.0.0.2", TargetPort: "83"},
}})
if err != nil {
t.Fatal(err)
}
want := []forwardingCall{
{operation: "remove", rule: forwardClient.Rule{Num: "3", Protocol: "tcp", Port: "8003", TargetIP: "10.0.0.2", TargetPort: "83"}},
{operation: "remove", rule: forwardClient.Rule{Num: "1", Protocol: "tcp", Port: "8001", TargetIP: "10.0.0.2", TargetPort: "81"}},
{operation: "add", rule: forwardClient.Rule{Protocol: "tcp", Port: "9000", TargetIP: "10.0.0.2", TargetPort: "90"}},
{operation: "add", rule: forwardClient.Rule{Protocol: "udp", Port: "9000", TargetIP: "10.0.0.2", TargetPort: "90"}},
}
if !reflect.DeepEqual(adapter.calls, want) {
t.Fatalf("operation order changed\ngot %#v\nwant %#v", adapter.calls, want)
}
}
func TestForwardingSearchReturnsAdapterError(t *testing.T) {
wantErr := errors.New("list failed")
service := forwardingServiceWithAdapter(&fakeForwardingAdapter{name: "firewalld", listErr: wantErr})
_, _, err := service.SearchWithPage(dto.ForwardRuleSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 20}})
if !errors.Is(err, wantErr) {
t.Fatalf("got %v want %v", err, wantErr)
}
}
+23 -71
View File
@@ -29,7 +29,6 @@ import (
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/pkg/archive"
"github.com/docker/docker/pkg/homedir"
)
type ImageService struct{}
@@ -278,38 +277,37 @@ func (u *ImageService) ImagePull(req dto.ImagePull) error {
itemName := strings.ReplaceAll(path.Base(item), ":", "_")
taskItem.AddSubTask(i18n.GetWithName("ImagePull", itemName), func(t *task.Task) error {
taskItem.Logf("----------------- %s -----------------", itemName)
if req.RepoID == 0 {
pullErr := pullImages(taskItem, client, item)
taskItem.LogWithStatus(i18n.GetMsgByKey("TaskPull"), pullErr)
return pullErr
}
options := image.PullOptions{}
imageName := item
if req.RepoID == 0 {
hasAuth, authStr := loadAuthInfo(item)
if hasAuth {
options.RegistryAuth = authStr
repo, repoErr := imageRepoRepo.Get(repo.WithByID(req.RepoID))
taskItem.LogWithStatus(i18n.GetMsgByKey("ImageRepoAuthFromDB"), repoErr)
if repoErr != nil {
return repoErr
}
if repo.Auth {
authConfig := registry.AuthConfig{
Username: repo.Username,
Password: repo.Password,
}
} else {
repo, err := imageRepoRepo.Get(repo.WithByID(req.RepoID))
taskItem.LogWithStatus(i18n.GetMsgByKey("ImageRepoAuthFromDB"), err)
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
return err
}
if repo.Auth {
authConfig := registry.AuthConfig{
Username: repo.Username,
Password: repo.Password,
}
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
return err
}
authStr := base64.URLEncoding.EncodeToString(encodedJSON)
options.RegistryAuth = authStr
}
imageName = repo.DownloadUrl + "/" + item
authStr := base64.URLEncoding.EncodeToString(encodedJSON)
options.RegistryAuth = authStr
}
imageName = repo.DownloadUrl + "/" + item
dockerCli := docker.NewClientWithExist(client)
err = dockerCli.PullImageWithProcessAndOptions(taskItem, imageName, options)
taskItem.LogWithStatus(i18n.GetMsgByKey("TaskPull"), err)
if err != nil {
return err
pullErr := dockerCli.PullImageWithProcessAndOptions(taskItem, imageName, options)
taskItem.LogWithStatus(i18n.GetMsgByKey("TaskPull"), pullErr)
if pullErr != nil {
return pullErr
}
return nil
}, nil)
@@ -547,49 +545,3 @@ func checkUsed(imageID string, containers []container.Summary) bool {
}
return false
}
func loadAuthInfo(image string) (bool, string) {
if !strings.Contains(image, "/") {
return false, ""
}
homeDir := homedir.Get()
confPath := path.Join(homeDir, ".docker/config.json")
configFileBytes, err := os.ReadFile(confPath)
if err != nil {
return false, ""
}
var config dockerConfig
if err = json.Unmarshal(configFileBytes, &config); err != nil {
return false, ""
}
var (
user string
passwd string
)
imagePrefix := strings.Split(image, "/")[0]
if val, ok := config.Auths[imagePrefix]; ok {
itemByte, _ := base64.StdEncoding.DecodeString(val.Auth)
itemStr := string(itemByte)
if strings.Contains(itemStr, ":") {
user = strings.Split(itemStr, ":")[0]
passwd = strings.Split(itemStr, ":")[1]
}
}
authConfig := registry.AuthConfig{
Username: user,
Password: passwd,
}
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
return false, ""
}
authStr := base64.URLEncoding.EncodeToString(encodedJSON)
return true, authStr
}
type dockerConfig struct {
Auths map[string]authConfig `json:"auths"`
}
type authConfig struct {
Auth string `json:"auth"`
}
-506
View File
@@ -1,506 +0,0 @@
package service
import (
"errors"
"fmt"
"net"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/client/iptables"
)
type IIptablesService interface {
Search(req dto.SearchPageWithType) (int64, interface{}, error)
OperateRule(req dto.IptablesRuleOp, withSave bool) error
BatchOperate(req dto.IptablesBatchOperate) error
LoadChainStatus(req dto.OperationWithName) dto.IptablesChainStatus
Operate(req dto.IptablesOp) error
}
type IptablesService struct{}
func NewIIptablesService() IIptablesService {
return &IptablesService{}
}
func (s *IptablesService) Search(req dto.SearchPageWithType) (int64, interface{}, error) {
rules, err := iptables.ReadFilterRulesByChain(req.Type)
if err != nil {
return 0, nil, fmt.Errorf("failed to read iptables rules: %w", err)
}
var records []iptables.FilterRules
total, start, end := len(rules), (req.Page-1)*req.PageSize, req.Page*req.PageSize
if start > total {
records = make([]iptables.FilterRules, 0)
} else {
if end >= total {
end = total
}
records = rules[start:end]
}
rulesInDB, _ := hostRepo.ListFirewallRecord(hostRepo.WithByChain(req.Type))
for i := 0; i < len(records); i++ {
for _, item := range rulesInDB {
if records[i].Strategy == item.Strategy &&
records[i].DstIP == item.DstIP &&
fmt.Sprintf("%v", records[i].DstPort) == item.DstPort &&
records[i].Protocol == item.Protocol &&
records[i].SrcIP == item.SrcIP &&
fmt.Sprintf("%v", records[i].SrcPort) == item.SrcPort {
records[i].ID = item.ID
records[i].Description = item.Description
}
}
}
return int64(total), records, nil
}
func (s *IptablesService) OperateRule(req dto.IptablesRuleOp, withSave bool) error {
action := "ACCEPT"
if req.Strategy == "drop" {
action = "DROP"
}
policy := iptables.FilterRules{
Protocol: req.Protocol,
SrcIP: req.SrcIP,
DstIP: req.DstIP,
Strategy: action,
}
if req.SrcPort != 0 {
policy.SrcPort = fmt.Sprintf("%v", req.SrcPort)
}
if req.DstPort != 0 {
policy.DstPort = fmt.Sprintf("%v", req.DstPort)
}
name := iptables.InputFileName
if req.Chain == iptables.Chain1PanelOutput {
name = iptables.OutputFileName
}
switch req.Operation {
case "add":
if err := s.validateRuleInput(&req); err != nil {
return err
}
if err := iptables.AddFilterRule(req.Chain, policy); err != nil {
return fmt.Errorf("failed to add iptables rule: %w", err)
}
if len(req.Description) != 0 {
rule := &model.Firewall{
Chain: req.Chain,
Protocol: req.Protocol,
SrcIP: req.SrcIP,
SrcPort: policy.SrcPort,
DstIP: req.DstIP,
DstPort: policy.DstPort,
Strategy: req.Strategy,
Description: req.Description,
}
if err := hostRepo.SaveFirewallRecord(rule); err != nil {
return fmt.Errorf("failed to save rule to database: %w", err)
}
}
case "remove":
if err := iptables.DeleteFilterRule(req.Chain, policy); err != nil {
return fmt.Errorf("failed to remove iptables rule: %w", err)
}
if req.ID != 0 {
if err := hostRepo.DeleteFirewallRecordByID(req.ID); err != nil {
return fmt.Errorf("failed to delete rule from database: %w", err)
}
}
}
if !withSave {
return nil
}
if err := iptables.SaveRulesToFile(iptables.FilterTab, req.Chain, name); err != nil {
global.LOG.Errorf("persistence for %s failed, err: %v", iptables.Chain1PanelBasic, err)
}
return nil
}
func (s *IptablesService) BatchOperate(req dto.IptablesBatchOperate) error {
if len(req.Rules) == 0 {
return errors.New("no rules to operate")
}
for _, rule := range req.Rules {
if err := s.OperateRule(rule, false); err != nil {
return err
}
}
chain := iptables.Chain1PanelInput
fileName := iptables.InputFileName
if req.Rules[0].Chain == iptables.Chain1PanelOutput {
chain = iptables.Chain1PanelOutput
fileName = iptables.OutputFileName
}
if err := iptables.SaveRulesToFile(iptables.FilterTab, chain, fileName); err != nil {
global.LOG.Errorf("persistence for %s failed, err: %v", iptables.Chain1PanelBasic, err)
}
return nil
}
func (s *IptablesService) Operate(req dto.IptablesOp) error {
targetChain := iptables.ChainInput
if req.Name == iptables.Chain1PanelOutput {
targetChain = iptables.ChainOutput
}
switch req.Operate {
case "init-base":
if ok := cmd.Which("iptables"); !ok {
return fmt.Errorf("failed to find iptables")
}
if err := iptables.AddChain(iptables.FilterTab, iptables.Chain1PanelBasicBefore); err != nil {
return err
}
if err := iptables.AddChain(iptables.FilterTab, iptables.Chain1PanelBasic); err != nil {
return err
}
if err := iptables.AddChain(iptables.FilterTab, iptables.Chain1PanelBasicAfter); err != nil {
return err
}
if err := initPreRules(); err != nil {
return err
}
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasicBefore, 1); err != nil {
return err
}
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasic, 2); err != nil {
return err
}
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasicAfter, 3); err != nil {
return err
}
if err := iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelBasicBefore, iptables.BasicBeforeFileName); err != nil {
return err
}
if err := iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelBasic, iptables.BasicFileName); err != nil {
return err
}
if err := iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelBasicAfter, iptables.BasicAfterFileName); err != nil {
return err
}
_ = settingRepo.Update("IptablesStatus", constant.StatusEnable)
return nil
case "init-advance":
if err := iptables.AddChain(iptables.FilterTab, iptables.Chain1PanelInput); err != nil {
return err
}
if err := iptables.AddChain(iptables.FilterTab, iptables.Chain1PanelOutput); err != nil {
return err
}
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainOutput, iptables.Chain1PanelOutput, 1); err != nil {
return err
}
number := loadBindNumber(iptables.Chain1PanelInput)
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelInput, number); err != nil {
return err
}
_ = settingRepo.Update("IptablesInputStatus", constant.StatusEnable)
_ = settingRepo.Update("IptablesOutputStatus", constant.StatusEnable)
return nil
case "bind-base":
if err := initPreRules(); err != nil {
return err
}
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasicBefore, 1); err != nil {
return err
}
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasic, 2); err != nil {
return err
}
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasicAfter, 3); err != nil {
return err
}
if err := iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelBasicBefore, iptables.BasicBeforeFileName); err != nil {
return err
}
if err := iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelBasic, iptables.BasicFileName); err != nil {
return err
}
if err := iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelBasicAfter, iptables.BasicAfterFileName); err != nil {
return err
}
_ = settingRepo.Update("IptablesStatus", constant.StatusEnable)
return nil
case "bind-base-without-init":
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasicBefore, 1); err != nil {
return err
}
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasic, 2); err != nil {
return err
}
if err := iptables.BindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasicAfter, 3); err != nil {
return err
}
_ = settingRepo.Update("IptablesStatus", constant.StatusEnable)
return nil
case "unbind-base":
if err := iptables.UnbindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasicAfter); err != nil {
return err
}
if err := iptables.UnbindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasicBefore); err != nil {
return err
}
if err := iptables.UnbindChain(iptables.FilterTab, iptables.ChainInput, iptables.Chain1PanelBasic); err != nil {
return err
}
_ = settingRepo.Update("IptablesStatus", constant.StatusDisable)
return nil
case "bind":
if err := iptables.BindChain(iptables.FilterTab, targetChain, req.Name, loadBindNumber(req.Name)); err != nil {
return err
}
if req.Name == iptables.Chain1PanelInput {
_ = settingRepo.Update("IptablesInputStatus", constant.StatusEnable)
}
if req.Name == iptables.Chain1PanelOutput {
_ = settingRepo.Update("IptablesOutputStatus", constant.StatusEnable)
}
return nil
case "unbind":
if err := iptables.UnbindChain(iptables.FilterTab, targetChain, req.Name); err != nil {
return err
}
if req.Name == iptables.Chain1PanelInput {
_ = settingRepo.Update("IptablesInputStatus", constant.StatusDisable)
}
if req.Name == iptables.Chain1PanelOutput {
_ = settingRepo.Update("IptablesOutputStatus", constant.StatusDisable)
}
return nil
}
return nil
}
func (s *IptablesService) LoadChainStatus(req dto.OperationWithName) dto.IptablesChainStatus {
var data dto.IptablesChainStatus
var err error
data.DefaultStrategy, err = iptables.LoadDefaultStrategy(req.Name)
if err != nil {
global.LOG.Error(err)
}
switch req.Name {
case iptables.Chain1PanelBasic:
data.IsBind, _ = iptables.CheckChainBind(iptables.FilterTab, iptables.ChainInput, req.Name)
case iptables.Chain1PanelInput:
data.IsBind, _ = iptables.CheckChainBind(iptables.FilterTab, iptables.ChainInput, req.Name)
case iptables.Chain1PanelOutput:
data.IsBind, _ = iptables.CheckChainBind(iptables.FilterTab, iptables.ChainOutput, req.Name)
}
return data
}
func (s *IptablesService) validateRuleInput(req *dto.IptablesRuleOp) error {
if req.Protocol != "" {
validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true, "all": true}
if !validProtocols[strings.ToLower(req.Protocol)] {
return fmt.Errorf("invalid protocol: %s, must be tcp, udp, icmp or all", req.Protocol)
}
}
if req.SrcIP != "" {
if err := s.validateIPOrCIDR(req.SrcIP); err != nil {
return fmt.Errorf("invalid source IP: %w", err)
}
}
if req.DstIP != "" {
if err := s.validateIPOrCIDR(req.DstIP); err != nil {
return fmt.Errorf("invalid destination IP: %w", err)
}
}
if req.SrcPort > 65535 {
return fmt.Errorf("invalid source port: %d, must be between 1 and 65535", req.SrcPort)
}
if req.DstPort > 65535 {
return fmt.Errorf("invalid destination port: %d, must be between 1 and 65535", req.DstPort)
}
if (req.SrcPort > 0 || req.DstPort > 0) && req.Protocol == "" {
return fmt.Errorf("port specification requires protocol (tcp/udp)")
}
return nil
}
func (s *IptablesService) validateIPOrCIDR(ipStr string) error {
if strings.Contains(ipStr, "/") {
_, _, err := net.ParseCIDR(ipStr)
if err != nil {
return fmt.Errorf("invalid CIDR format: %w", err)
}
return nil
}
ip := net.ParseIP(ipStr)
if ip == nil {
return fmt.Errorf("invalid IP address format")
}
return nil
}
func loadBindNumber(chain string) int {
if chain == iptables.Chain1PanelOutput {
return 1
}
number := 1
if exist, _ := iptables.CheckChainExist(iptables.FilterTab, iptables.Chain1PanelBasicBefore); exist {
number++
}
if exist, _ := iptables.CheckChainExist(iptables.FilterTab, iptables.Chain1PanelBasic); exist {
number++
}
return number
}
func initPreRules() error {
if err := iptables.AddRule(iptables.FilterTab, iptables.Chain1PanelBasicBefore, "-i", "lo", "-j", "ACCEPT", "-m", "comment", "--comment", "Loopback Whitelist"); err != nil {
return err
}
if err := iptables.AddRule(iptables.FilterTab, iptables.Chain1PanelBasicBefore, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT", "-m", "comment", "--comment", "ESTABLISHED Whitelist"); err != nil {
return err
}
if err := syncIptablesFirewallPortWhiteList(false); err != nil {
return err
}
if err := iptables.AddRule(iptables.FilterTab, iptables.Chain1PanelBasicAfter, "-p", "tcp", "-j", "DROP"); err != nil {
return err
}
if err := iptables.AddRule(iptables.FilterTab, iptables.Chain1PanelBasicAfter, "-p", "udp", "-j", "DROP"); err != nil {
return err
}
return nil
}
func syncIptablesFirewallPortWhiteList(withSave bool, oldConfiguredPortWhiteList ...[]firewallPortWhitelist) error {
requiredPorts, err := loadRequiredFirewallPortWhiteList()
if err != nil {
return err
}
if err := applyRequiredFirewallPortWhiteListRules(requiredPorts, withSave); err != nil {
return err
}
portWhiteList, err := loadConfiguredFirewallPortWhiteList()
if err != nil {
return err
}
return applyFirewallPortWhiteListRules(portWhiteList, withSave, oldConfiguredPortWhiteList...)
}
func applyRequiredFirewallPortWhiteListRules(portWhiteList []firewallPortWhitelist, withSave bool) error {
if err := syncRequiredFirewallPortWhiteListRules(portWhiteList); err != nil {
return err
}
for _, item := range portWhiteList {
if err := iptables.AddRule(iptables.FilterTab, iptables.Chain1PanelBasicBefore, "-p", item.Protocol, "-m", item.Protocol, "--dport", item.Port, "-j", "ACCEPT"); err != nil {
return err
}
}
if !withSave {
return nil
}
if err := iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelBasicBefore, iptables.BasicBeforeFileName); err != nil {
return err
}
return iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelBasicAfter, iptables.BasicAfterFileName)
}
func applyFirewallPortWhiteListRules(portWhiteList []firewallPortWhitelist, withSave bool, oldConfiguredPortWhiteList ...[]firewallPortWhitelist) error {
if err := syncFirewallPortWhiteListRules(portWhiteList, oldConfiguredPortWhiteList...); err != nil {
return err
}
for _, item := range portWhiteList {
if err := iptables.AddRule(iptables.FilterTab, iptables.Chain1PanelBasic, "-p", item.Protocol, "-m", item.Protocol, "--dport", item.Port, "-j", "ACCEPT"); err != nil {
return err
}
}
if !withSave {
return nil
}
return iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelBasic, iptables.BasicFileName)
}
func syncRequiredFirewallPortWhiteListRules(portWhiteList []firewallPortWhitelist) error {
tcpWhitelist := make(map[string]struct{})
udpWhitelist := make(map[string]struct{})
for _, item := range portWhiteList {
if item.Protocol == "udp" {
udpWhitelist[item.Port] = struct{}{}
continue
}
tcpWhitelist[item.Port] = struct{}{}
}
if err := cleanExtraFirewallPortRules(iptables.Chain1PanelBasicBefore, "tcp", tcpWhitelist); err != nil {
return err
}
if err := cleanExtraFirewallPortRules(iptables.Chain1PanelBasicBefore, "udp", udpWhitelist); err != nil {
return err
}
return cleanExtraFirewallPortRules(iptables.Chain1PanelBasicAfter, "udp", map[string]struct{}{})
}
func syncFirewallPortWhiteListRules(portWhiteList []firewallPortWhitelist, oldConfiguredPortWhiteList ...[]firewallPortWhitelist) error {
portWhitelist := firewallPortWhiteListMap(portWhiteList)
if len(oldConfiguredPortWhiteList) == 0 {
return nil
}
for _, item := range oldConfiguredPortWhiteList[0] {
if _, ok := portWhitelist[firewallPortWhiteListKey(item)]; ok {
continue
}
if !iptables.CheckRuleExist(iptables.FilterTab, iptables.Chain1PanelBasic, "-p", item.Protocol, "--dport", item.Port, "-j", "ACCEPT") {
continue
}
if err := iptables.DeleteRule(iptables.FilterTab, iptables.Chain1PanelBasic, "-p", item.Protocol, "--dport", item.Port, "-j", "ACCEPT"); err != nil {
return err
}
}
return nil
}
func cleanExtraFirewallPortRules(chain, protocol string, whitelist map[string]struct{}) error {
rules, err := iptables.ReadFilterRulesByChain(chain)
if err != nil {
return err
}
kept := make(map[string]struct{})
for _, rule := range rules {
if rule.Strategy != "accept" || rule.Protocol != protocol || rule.DstPort == "" || rule.SrcIP != "" || rule.DstIP != "" || rule.SrcPort != "" {
continue
}
if _, ok := whitelist[rule.DstPort]; ok {
if _, seen := kept[rule.DstPort]; !seen {
kept[rule.DstPort] = struct{}{}
continue
}
}
if err := iptables.DeleteRule(iptables.FilterTab, chain, "-p", protocol, "-m", protocol, "--dport", rule.DstPort, "-j", "ACCEPT"); err != nil {
return err
}
}
return nil
}
func LoadPanelPort() string {
if !global.IsMaster {
return global.CONF.Base.Port
} else {
var portSetting model.Setting
_ = global.CoreDB.Where("key = ?", "ServerPort").First(&portSetting).Error
if len(portSetting.Value) != 0 {
return portSetting.Value
}
}
return ""
}
+103 -130
View File
@@ -8,7 +8,6 @@ import (
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -19,8 +18,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/gpu"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/xpu"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/psutil"
"github.com/robfig/cron/v3"
@@ -130,63 +128,75 @@ func (m *MonitorService) LoadMonitorData(req dto.MonitorSearch) ([]dto.MonitorDa
func (m *MonitorService) LoadGPUOptions() dto.MonitorGPUOptions {
var data dto.MonitorGPUOptions
gpuExist, gpuClient := gpu.New()
xpuExist, xpuClient := xpu.New()
if !gpuExist && !xpuExist {
exist, client := accelerator.New()
if !exist {
return data
}
if gpuExist {
data.GPUType = "gpu"
gpuInfo, err := gpuClient.LoadGpuInfo()
if err != nil || len(gpuInfo.GPUs) == 0 {
global.LOG.Error("Load GPU info failed or no GPU found, err: ", err)
return data
}
sort.Slice(gpuInfo.GPUs, func(i, j int) bool {
return gpuInfo.GPUs[i].Index < gpuInfo.GPUs[j].Index
})
for _, item := range gpuInfo.GPUs {
var chartHide dto.GPUChartHide
chartHide.ProductName = fmt.Sprintf("%d - %s", item.Index, item.ProductName)
chartHide.GPU = item.GPUUtil == "" || item.GPUUtil == "N/A"
if (item.MemTotal == "" || item.MemTotal == "N/A") && (item.MemUsed == "" || item.MemUsed == "N/A") {
chartHide.Memory = true
}
if (item.MaxPowerLimit == "" || item.MaxPowerLimit == "N/A") && (item.PowerDraw == "" || item.PowerDraw == "N/A") {
chartHide.Power = true
}
chartHide.Temperature = item.Temperature == "" || item.Temperature == "N/A"
chartHide.Speed = item.FanSpeed == "" || item.FanSpeed == "N/A"
data.ChartHide = append(data.ChartHide, chartHide)
data.Options = append(data.Options, fmt.Sprintf("%d - %s", item.Index, item.ProductName))
}
snapshot, err := client.Collect(context.Background())
if err != nil {
global.LOG.Errorf("Load accelerator info failed, err: %v", err)
return data
} else {
}
if warning := snapshot.Warning(); warning != nil {
global.LOG.Warnf("Load accelerator info partially failed, err: %v", warning)
}
return loadGPUOptions(snapshot)
}
func loadGPUOptions(snapshot *accelerator.Snapshot) dto.MonitorGPUOptions {
var data dto.MonitorGPUOptions
hasGPUOrNPU := false
hasXPU := false
for _, item := range snapshot.Devices {
if item.Kind == accelerator.KindXPU {
hasXPU = true
} else {
hasGPUOrNPU = true
}
}
switch {
case hasGPUOrNPU && hasXPU:
data.GPUType = "mixed"
case hasXPU:
data.GPUType = "xpu"
xpu, err := xpuClient.LoadGpuInfo()
if err != nil || len(xpu.Xpu) == 0 {
global.LOG.Error("Load XPU info failed or no XPU found, err: ", err)
}
sort.Slice(xpu.Xpu, func(i, j int) bool {
return xpu.Xpu[i].Basic.DeviceID < xpu.Xpu[j].Basic.DeviceID
})
for _, item := range xpu.Xpu {
var chartHide dto.GPUChartHide
chartHide.GPU = true
chartHide.Speed = true
chartHide.ProductName = fmt.Sprintf("%d - %s", item.Basic.DeviceID, item.Basic.DeviceName)
if (item.Stats.MemoryUsed == "" || item.Stats.MemoryUsed == "N/A") && (item.Basic.Memory == "" || item.Basic.FreeMemory == "N/A") {
chartHide.Memory = true
}
if item.Stats.Power == "" || item.Stats.Power == "N/A" {
chartHide.Power = true
}
chartHide.Temperature = item.Stats.Temperature == "" || item.Stats.Temperature == "N/A"
data.ChartHide = append(data.ChartHide, chartHide)
data.Options = append(data.Options, fmt.Sprintf("%d - %s", item.Basic.DeviceID, item.Basic.DeviceName))
}
return data
case hasGPUOrNPU:
data.GPUType = "gpu"
}
sort.Slice(snapshot.Devices, func(i, j int) bool {
if snapshot.Devices[i].Kind != snapshot.Devices[j].Kind {
return snapshot.Devices[i].Kind < snapshot.Devices[j].Kind
}
if snapshot.Devices[i].Vendor != snapshot.Devices[j].Vendor {
return snapshot.Devices[i].Vendor < snapshot.Devices[j].Vendor
}
if snapshot.Devices[i].NPUIndex != snapshot.Devices[j].NPUIndex {
return snapshot.Devices[i].NPUIndex < snapshot.Devices[j].NPUIndex
}
if snapshot.Devices[i].ChipIndex != snapshot.Devices[j].ChipIndex {
return snapshot.Devices[i].ChipIndex < snapshot.Devices[j].ChipIndex
}
return snapshot.Devices[i].Index < snapshot.Devices[j].Index
})
for _, item := range snapshot.Devices {
optionType := "gpu"
if item.Kind == accelerator.KindXPU {
optionType = "xpu"
}
chartHide := dto.GPUChartHide{
ProductName: item.Label,
Type: optionType,
GPU: !item.Capabilities.Utilization,
Memory: !item.Capabilities.Memory,
Power: !item.Capabilities.Power,
PowerLimit: !item.Capabilities.PowerLimit,
Temperature: !item.Capabilities.Temperature,
Speed: !item.Capabilities.FanSpeed,
}
data.ChartHide = append(data.ChartHide, chartHide)
data.Options = append(data.Options, chartHide.ProductName)
}
return data
}
func (m *MonitorService) LoadGPUMonitorData(req dto.MonitorGPUSearch) (dto.MonitorGPUData, error) {
@@ -297,8 +307,7 @@ func (m *MonitorService) CleanData() error {
}
func (m *MonitorService) Run() {
saveGPUDataToDB()
saveXPUDataToDB()
saveAcceleratorDataToDB()
var itemModel model.MonitorBase
totalPercent, _ := cpu.Percent(3*time.Second, false)
if len(totalPercent) == 1 {
@@ -344,6 +353,7 @@ func (m *MonitorService) Run() {
_ = monitorRepo.DelMonitorBase(timeForDelete)
_ = monitorRepo.DelMonitorIO(timeForDelete)
_ = monitorRepo.DelMonitorNet(timeForDelete)
_ = monitorRepo.DelMonitorGPU(timeForDelete)
}
func (m *MonitorService) loadDiskIO() {
@@ -592,93 +602,56 @@ func StartMonitor(removeBefore bool, interval string) error {
return nil
}
func saveGPUDataToDB() {
exist, client := gpu.New()
func saveAcceleratorDataToDB() {
exist, client := accelerator.New()
if !exist {
return
}
gpuInfo, err := client.LoadGpuInfo()
snapshot, err := client.Collect(context.Background())
if err != nil {
global.LOG.Errorf("load accelerator monitor data failed, err: %v", err)
return
}
var list []model.MonitorGPU
for _, gpuItem := range gpuInfo.GPUs {
item := model.MonitorGPU{
ProductName: fmt.Sprintf("%d - %s", gpuItem.Index, gpuItem.ProductName),
GPUUtil: loadGPUInfoFloat(gpuItem.GPUUtil),
Temperature: loadGPUInfoFloat(gpuItem.Temperature),
PowerDraw: loadGPUInfoFloat(gpuItem.PowerDraw),
MaxPowerLimit: loadGPUInfoFloat(gpuItem.MaxPowerLimit),
MemUsed: loadGPUInfoFloat(gpuItem.MemUsed),
MemTotal: loadGPUInfoFloat(gpuItem.MemTotal),
FanSpeed: loadGPUInfoInt(gpuItem.FanSpeed),
}
process, _ := json.Marshal(gpuItem.Processes)
if len(process) != 0 {
item.Processes = string(process)
}
list = append(list, item)
if warning := snapshot.Warning(); warning != nil {
global.LOG.Warnf("load accelerator monitor data partially failed, err: %v", warning)
}
list := make([]model.MonitorGPU, 0, len(snapshot.Devices))
for _, device := range snapshot.Devices {
list = append(list, newMonitorGPU(device))
}
if err := repo.NewIMonitorRepo().BatchCreateMonitorGPU(list); err != nil {
global.LOG.Errorf("batch create gpu monitor data failed, err: %v", err)
return
global.LOG.Errorf("batch create accelerator monitor data failed, err: %v", err)
}
}
func saveXPUDataToDB() {
exist, client := xpu.New()
if !exist {
return
func newMonitorGPU(device accelerator.Device) model.MonitorGPU {
item := model.MonitorGPU{
ProductName: device.Label,
GPUUtil: device.Metrics.Utilization.ValueOrZero(),
Temperature: device.Metrics.Temperature.ValueOrZero(),
PowerDraw: device.Metrics.Power.ValueOrZero(),
MaxPowerLimit: device.Metrics.PowerLimit.ValueOrZero(),
MemUsed: device.Metrics.MemoryUsed.ValueOrZero(),
MemTotal: device.Metrics.MemoryTotal.ValueOrZero(),
FanSpeed: int(device.Metrics.FanSpeed.ValueOrZero()),
}
xpuInfo, err := client.LoadGpuInfo()
if err != nil {
return
if len(device.Processes) == 0 {
return item
}
var list []model.MonitorGPU
for _, xpuItem := range xpuInfo.Xpu {
item := model.MonitorGPU{
ProductName: fmt.Sprintf("%d - %s", xpuItem.Basic.DeviceID, xpuItem.Basic.DeviceName),
Temperature: loadGPUInfoFloat(xpuItem.Stats.Temperature),
PowerDraw: loadGPUInfoFloat(xpuItem.Stats.Power),
MemUsed: loadGPUInfoFloat(xpuItem.Stats.MemoryUsed),
MemTotal: loadGPUInfoFloat(xpuItem.Basic.Memory),
}
if len(xpuItem.Processes) != 0 {
var processItem []dto.GPUProcess
for _, ps := range xpuItem.Processes {
processItem = append(processItem, dto.GPUProcess{
Pid: fmt.Sprintf("%v", ps.PID),
Type: ps.SHR,
ProcessName: ps.Command,
UsedMemory: ps.Memory,
})
}
process, _ := json.Marshal(processItem)
if len(process) != 0 {
item.Processes = string(process)
}
}
list = append(list, item)
processes := make([]dto.GPUProcess, 0, len(device.Processes))
for _, process := range device.Processes {
processes = append(processes, dto.GPUProcess{
Pid: process.PID,
Type: process.Type,
ProcessName: process.Name,
UsedMemory: process.Memory,
})
}
if err := repo.NewIMonitorRepo().BatchCreateMonitorGPU(list); err != nil {
global.LOG.Errorf("batch create gpu monitor data failed, err: %v", err)
return
processData, err := json.Marshal(processes)
if err == nil {
item.Processes = string(processData)
}
}
func loadGPUInfoInt(val string) int {
val = strings.TrimSuffix(val, "%")
val = strings.TrimSpace(val)
data, _ := strconv.Atoi(val)
return data
}
func loadGPUInfoFloat(val string) float64 {
val = strings.TrimSpace(val)
suffixes := []string{"W", "MB", "MiB", "°C", "C", "%"}
for _, suffix := range suffixes {
val = strings.TrimSuffix(val, suffix)
}
val = strings.TrimSpace(val)
data, _ := strconv.ParseFloat(val, 64)
return data
return item
}
func sumDiskIOCounters(ioStats map[string]disk.IOCountersStat) disk.IOCountersStat {
+8 -2
View File
@@ -32,7 +32,7 @@ type NginxService struct {
type INginxService interface {
GetNginxConfig() (*response.NginxFile, error)
GetConfigByScope(req request.NginxScopeReq) ([]response.NginxParam, error)
GetConfigByScope(req request.NginxScopeReq) (interface{}, error)
UpdateConfigByScope(req request.NginxConfigUpdate) error
GetStatus() (response.NginxStatus, error)
UpdateConfigFile(req request.NginxConfigFileUpdate) error
@@ -62,7 +62,10 @@ func (n NginxService) GetNginxConfig() (*response.NginxFile, error) {
return &response.NginxFile{Content: string(byteContent)}, nil
}
func (n NginxService) GetConfigByScope(req request.NginxScopeReq) ([]response.NginxParam, error) {
func (n NginxService) GetConfigByScope(req request.NginxScopeReq) (interface{}, error) {
if req.Scope == dto.Brotli {
return getNginxBrotliParams()
}
keys, ok := dto.ScopeKeyMap[req.Scope]
if !ok || len(keys) == 0 {
return nil, nil
@@ -71,6 +74,9 @@ func (n NginxService) GetConfigByScope(req request.NginxScopeReq) ([]response.Ng
}
func (n NginxService) UpdateConfigByScope(req request.NginxConfigUpdate) error {
if req.Scope == dto.Brotli {
return updateNginxBrotliParams(getNginxParams(req.Params, dto.BrotliKeys))
}
keys, ok := dto.ScopeKeyMap[req.Scope]
if !ok || len(keys) == 0 {
return nil
+168
View File
@@ -0,0 +1,168 @@
package service
import (
"os"
"path"
"regexp"
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
)
// stockNginxGzipDirectives is the gzip block shipped by the OpenResty app
// since 1.21.4.3. The upgrade only rewrites values when the installed
// nginx.conf still carries exactly these directives and values, which proves
// the user never tuned compression. Any deviation aborts the rewrite.
var stockNginxGzipDirectives = map[string]string{
"gzip": "on",
"gzip_min_length": "1k",
"gzip_buffers": "4 16k",
"gzip_http_version": "1.1",
"gzip_comp_level": "2",
"gzip_types": "text/plain application/javascript application/x-javascript text/javascript text/css application/xml",
"gzip_vary": "on",
"gzip_proxied": "expired no-cache no-store private auth",
"gzip_disable": `"MSIE [1-6]\."`,
}
// correctedNginxGzipDirectives replaces the stock values in place. gzip lives
// in the http block of nginx.conf and must stay there: repeating it from an
// included file would make nginx reject the configuration with a duplicate
// directive error, and the compression settings page reads and writes these
// same keys in nginx.conf.
var correctedNginxGzipDirectives = map[string]string{
"gzip_comp_level": "5",
"gzip_types": strings.Join(nginxCompressibleTypes, " "),
"gzip_proxied": "any",
}
// obsoleteNginxGzipDirectives are dropped outright.
var obsoleteNginxGzipDirectives = map[string]struct{}{
// A per-request User-Agent regex for browsers with no measurable share.
"gzip_disable": {},
}
var nginxGzipDirectiveRe = regexp.MustCompile(`(?m)^[ \t]*(gzip[a-z_]*)[ \t]+([^;\n]*);[ \t]*$`)
func nginxMainConfigPath(install model.AppInstall) string {
return path.Join(install.GetPath(), nginxModuleConfDir, "nginx.conf")
}
// upgradeStockNginxGzipConfig rewrites the factory gzip defaults in place.
//
// Upgrades deliberately preserve the user's nginx.conf, so corrected defaults
// shipped with a new OpenResty version would otherwise never reach existing
// installations.
//
// The config parser is not used: 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.
func upgradeStockNginxGzipConfig(install model.AppInstall) error {
configPath := nginxMainConfigPath(install)
content, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if !isStockNginxGzipConfig(string(content)) {
return nil
}
updated := rewriteNginxGzipDirectives(string(content))
if updated == string(content) {
return nil
}
if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil {
return err
}
if err = nginxCheckAndReload(string(content), configPath, install.ContainerName); err != nil {
return err
}
global.LOG.Info("updated the stock OpenResty gzip configuration to the current defaults")
return nil
}
// isStockNginxGzipConfig reports whether every gzip directive in the config
// matches the factory defaults exactly, with none missing and none extra.
func isStockNginxGzipConfig(content string) bool {
found := make(map[string]string)
for _, match := range nginxGzipDirectiveRe.FindAllStringSubmatch(content, -1) {
name := match[1]
value := strings.Join(strings.Fields(match[2]), " ")
if _, ok := found[name]; ok {
// A directive repeated in the http block means the config was
// edited by hand; leave it alone.
return false
}
found[name] = value
}
if len(found) != len(stockNginxGzipDirectives) {
return false
}
for name, expected := range stockNginxGzipDirectives {
if found[name] != expected {
return false
}
}
return true
}
// rewriteNginxGzipDirectives updates known values in place, drops obsolete
// directives and appends directives that are missing, preserving the original
// indentation and leaving every other line untouched.
func rewriteNginxGzipDirectives(content string) string {
lines := strings.Split(content, "\n")
result := make([]string, 0, len(lines))
seen := make(map[string]struct{})
lastGzipIndex := -1
lastGzipIndent := " "
for _, line := range lines {
match := nginxGzipDirectiveRe.FindStringSubmatch(line)
if match == nil {
result = append(result, line)
continue
}
name := match[1]
// The indentation belongs to the line itself; a top-level directive
// must not inherit the indent a previous, nested directive used.
lineIndent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
if lineIndent == "" {
lineIndent = " "
}
lastGzipIndent = lineIndent
if _, obsolete := obsoleteNginxGzipDirectives[name]; obsolete {
continue
}
seen[name] = struct{}{}
if replacement, ok := correctedNginxGzipDirectives[name]; ok {
result = append(result, lineIndent+name+" "+replacement+";")
} else {
result = append(result, line)
}
lastGzipIndex = len(result) - 1
}
// Directives introduced by a newer default set are appended right after
// the existing block so they stay visually grouped.
var missing []string
for name := range correctedNginxGzipDirectives {
if _, ok := seen[name]; !ok {
missing = append(missing, name)
}
}
if len(missing) == 0 || lastGzipIndex < 0 {
return strings.Join(result, "\n")
}
sort.Strings(missing)
added := make([]string, 0, len(missing))
for _, name := range missing {
added = append(added, lastGzipIndent+name+" "+correctedNginxGzipDirectives[name]+";")
}
tail := append(added, result[lastGzipIndex+1:]...)
return strings.Join(append(result[:lastGzipIndex+1], tail...), "\n")
}
@@ -0,0 +1,182 @@
package service
import (
"strings"
"testing"
"github.com/1Panel-dev/1Panel/agent/cmd/server/nginx_conf"
)
const stockNginxConf = `user root;
worker_processes auto;
include /usr/local/openresty/nginx/conf/modules-enabled/*.conf;
events {
use epoll;
}
http {
include mime.types;
default_type application/octet-stream;
server_names_hash_bucket_size 512;
keepalive_requests 5000;
gzip on;
gzip_min_length 1k;
gzip_buffers 4 16k;
gzip_http_version 1.1;
gzip_comp_level 2;
gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/xml;
gzip_vary on;
gzip_proxied expired no-cache no-store private auth;
gzip_disable "MSIE [1-6]\.";
limit_conn_zone $binary_remote_addr zone=perip:10m;
include /usr/local/openresty/nginx/conf/http.d/*.conf;
include /usr/local/openresty/nginx/conf/conf.d/*.conf;
}
`
func TestIsStockNginxGzipConfig(t *testing.T) {
if !isStockNginxGzipConfig(stockNginxConf) {
t.Fatal("factory configuration should be detected as stock")
}
}
func TestIsStockNginxGzipConfigRejectsTunedValues(t *testing.T) {
cases := map[string]string{
"comp level changed": strings.Replace(stockNginxConf, "gzip_comp_level 2;", "gzip_comp_level 6;", 1),
"gzip disabled": strings.Replace(stockNginxConf, "gzip on;", "gzip off;", 1),
"types extended": strings.Replace(stockNginxConf,
"application/xml;", "application/xml application/json;", 1),
"directive removed": strings.Replace(stockNginxConf, " gzip_vary on;\n", "", 1),
"directive added": strings.Replace(stockNginxConf, " gzip_vary on;\n",
" gzip_vary on;\n gzip_static on;\n", 1),
}
for name, content := range cases {
if isStockNginxGzipConfig(content) {
t.Errorf("%s: tuned configuration must not be rewritten", name)
}
}
}
func TestIsStockNginxGzipConfigRejectsDuplicateDirective(t *testing.T) {
content := strings.Replace(stockNginxConf, " gzip on;\n", " gzip on;\n gzip on;\n", 1)
if isStockNginxGzipConfig(content) {
t.Fatal("a duplicated directive indicates a hand-edited config")
}
}
func TestRewriteNginxGzipDirectives(t *testing.T) {
result := rewriteNginxGzipDirectives(stockNginxConf)
for _, expected := range []string{
" gzip_comp_level 5;",
" gzip_proxied any;",
" gzip on;",
" gzip_vary on;",
} {
if !strings.Contains(result, expected) {
t.Errorf("expected directive missing: %s\n%s", expected, result)
}
}
if !strings.Contains(result, "application/json") {
t.Error("gzip_types should now cover application/json")
}
if strings.Contains(result, "gzip_disable") {
t.Error("obsolete gzip_disable should have been dropped")
}
if strings.Contains(result, "gzip_comp_level 2;") {
t.Error("stale comp level should have been replaced")
}
// Everything outside the gzip block must survive untouched.
for _, keep := range []string{
"server_names_hash_bucket_size 512;",
"keepalive_requests 5000;",
"limit_conn_zone $binary_remote_addr zone=perip:10m;",
"include /usr/local/openresty/nginx/conf/http.d/*.conf;",
"include /usr/local/openresty/nginx/conf/conf.d/*.conf;",
"include /usr/local/openresty/nginx/conf/modules-enabled/*.conf;",
"user root;",
} {
if !strings.Contains(result, keep) {
t.Errorf("unrelated line was altered or dropped: %s", keep)
}
}
if !strings.HasSuffix(result, "}\n") {
t.Error("trailing newline was not preserved")
}
}
func TestRewriteNginxGzipDirectivesIsIdempotent(t *testing.T) {
once := rewriteNginxGzipDirectives(stockNginxConf)
twice := rewriteNginxGzipDirectives(once)
if once != twice {
t.Errorf("rewrite is not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", once, twice)
}
}
func TestRewriteNginxGzipDirectivesAppendsMissing(t *testing.T) {
// gzip_proxied absent from the source must be appended, not silently lost.
content := strings.Replace(stockNginxConf,
" gzip_proxied expired no-cache no-store private auth;\n", "", 1)
result := rewriteNginxGzipDirectives(content)
if !strings.Contains(result, "gzip_proxied any;") {
t.Errorf("missing directive was not appended:\n%s", result)
}
if !strings.Contains(result, "limit_conn_zone $binary_remote_addr zone=perip:10m;") {
t.Error("appending must not clobber following lines")
}
}
func TestRewriteNginxGzipDirectivesKeepsGzipLikeNames(t *testing.T) {
// gunzip and proxy_set_header must survive: only directives whose name
// starts with "gzip" are managed here.
content := "http {\n gunzip on;\n gzip on;\n proxy_set_header Accept-Encoding gzip;\n}\n"
result := rewriteNginxGzipDirectives(content)
if !strings.Contains(result, "gunzip on;") {
t.Error("gunzip directive must be preserved")
}
if !strings.Contains(result, "proxy_set_header Accept-Encoding gzip;") {
t.Error("proxy_set_header must be preserved")
}
if !strings.Contains(result, " gzip on;") {
t.Error("gzip directive should be kept in place")
}
}
// The gzip.conf template, the upgrade maps and the appstore defaults are three
// copies of one intent. Pin the first two so they cannot drift apart silently.
func TestGzipTemplateMatchesCorrectedDefaults(t *testing.T) {
template := nginx_conf.GetWebsiteFile("gzip.conf")
if len(template) == 0 {
t.Fatal("gzip.conf template is missing from the embedded files")
}
expected := make(map[string]string, len(stockNginxGzipDirectives))
for name, value := range stockNginxGzipDirectives {
expected[name] = value
}
for name := range obsoleteNginxGzipDirectives {
delete(expected, name)
}
for name, value := range correctedNginxGzipDirectives {
expected[name] = value
}
found := make(map[string]string)
for _, match := range nginxGzipDirectiveRe.FindAllStringSubmatch(string(template), -1) {
found[match[1]] = strings.Join(strings.Fields(match[2]), " ")
}
if len(found) != len(expected) {
t.Fatalf("template has %d directives, corrected defaults have %d", len(found), len(expected))
}
for name, want := range expected {
if got, ok := found[name]; !ok {
t.Errorf("template is missing %s", name)
} else if got != want {
t.Errorf("%s: template has %q, corrected defaults have %q", name, got, want)
}
}
}
+245
View File
@@ -0,0 +1,245 @@
package service
import (
"errors"
"fmt"
"os"
"path"
"regexp"
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
)
const (
// nginxHTTPConfDir holds http-context directives generated by 1Panel.
// load_module is a main-context directive and therefore lives in
// modules-enabled, which cannot host http-context directives such as
// "brotli on". The directory is included by nginx.conf before conf.d so
// that per-site configuration keeps overriding these defaults.
nginxHTTPConfDir = "http.d"
nginxHTTPConfigPrefix = "1panel-http-"
nginxHTTPConfigHeader = "# Managed by 1Panel. Manual changes will be overwritten.\n"
// nginxHTTPIncludeDirective is the include line that loads the managed
// directory. Fresh installs carry it in the shipped nginx.conf; existing
// ones get it inserted by the panel the first time a module needs
// http-context configuration.
nginxHTTPIncludeDirective = "include /usr/local/openresty/nginx/conf/http.d/*.conf;"
)
var (
// nginxHTTPIncludeRe matches the include line wherever it appears. The
// absolute path prefix, quoting and whitespace are all optional in the
// match so a variant written by an older installer or by hand still
// counts; a commented-out copy does not.
nginxHTTPIncludeRe = regexp.MustCompile(`(?m)^[ \t]*include\s+"?(/usr/local/openresty/nginx/conf/)?http\.d/\*\.conf"?\s*;[ \t]*\r?$`)
// nginxConfDIncludeRe locates the site-config include, the preferred
// insertion point, and captures its indentation.
nginxConfDIncludeRe = regexp.MustCompile(`(?m)^([ \t]*)include\s+"?(/usr/local/openresty/nginx/conf/)?conf\.d/\*\.conf"?\s*;[ \t]*\r?$`)
// nginxHTTPBlockStartRe locates the http block opening, the fallback
// insertion point, and captures its indentation.
nginxHTTPBlockStartRe = regexp.MustCompile(`(?m)^([ \t]*)http[ \t]*\{[ \t]*\r?$`)
)
// nginxHTTPIncludePresent reports whether nginx.conf already loads http.d.
func nginxHTTPIncludePresent(install model.AppInstall) bool {
content, err := os.ReadFile(nginxMainConfigPath(install))
if err != nil {
return false
}
return nginxHTTPIncludeRe.MatchString(string(content))
}
// writeNginxFileAtomic writes through a temp file plus rename so a crash or a
// concurrent reader never observes a half-written config.
func writeNginxFileAtomic(filePath string, content []byte) error {
tmpPath := filePath + ".tmp"
if err := os.WriteFile(tmpPath, content, constant.FilePerm); err != nil {
return err
}
return os.Rename(tmpPath, filePath)
}
// nginxFileLineEnding picks the file's own style so an inserted or rewritten
// line does not mix LF into a CRLF file.
func nginxFileLineEnding(content string) string {
if strings.Contains(content, "\r\n") {
return "\r\n"
}
return "\n"
}
// insertNginxHTTPInclude returns the config with the http.d include added.
//
// The include goes right before the conf.d include so panel-managed defaults
// are evaluated before per-site configuration; without one, it goes at the
// top of the http block. The inserted line follows the file's own line-ending
// style, and everything else stays byte-identical. A config without a
// locatable http block is rejected, and callers degrade instead of failing
// their operation over it.
func insertNginxHTTPInclude(content string) (string, error) {
if nginxHTTPIncludeRe.MatchString(content) {
return content, nil
}
eol := nginxFileLineEnding(content)
if m := nginxConfDIncludeRe.FindStringSubmatchIndex(content); m != nil {
indent := content[m[2]:m[3]]
return content[:m[0]] + indent + nginxHTTPIncludeDirective + eol + content[m[0]:], nil
}
if m := nginxHTTPBlockStartRe.FindStringSubmatchIndex(content); m != nil {
indent := content[m[2]:m[3]] + " "
return content[:m[1]] + eol + indent + nginxHTTPIncludeDirective + content[m[1]:], nil
}
return "", errors.New("no insertion point for the http.d include in nginx.conf")
}
// ensureNginxHTTPIncludeActive makes nginx.conf load http.d, inserting the
// include when missing. It returns whether the directory is loaded after the
// call, plus the original config content so the caller can roll back the edit
// together with the rest of its changes.
func ensureNginxHTTPIncludeActive(install model.AppInstall) (active bool, snapshot []byte, err error) {
configPath := nginxMainConfigPath(install)
content, readErr := os.ReadFile(configPath)
if readErr != nil {
return false, nil, readErr
}
if nginxHTTPIncludeRe.MatchString(string(content)) {
if err = os.MkdirAll(nginxHTTPConfigDir(install), constant.DirPerm); err != nil {
return false, nil, err
}
return true, nil, nil
}
updated, insErr := insertNginxHTTPInclude(string(content))
if insErr != nil {
return false, nil, insErr
}
if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil {
return false, nil, err
}
if err = os.MkdirAll(nginxHTTPConfigDir(install), constant.DirPerm); err != nil {
return false, nil, err
}
return true, content, nil
}
// nginxHTTPDirective is a single http-context directive rendered into a
// managed file.
type nginxHTTPDirective struct {
Name string
Params []string
}
func (d nginxHTTPDirective) render() string {
if len(d.Params) == 0 {
return d.Name + ";"
}
return d.Name + " " + strings.Join(d.Params, " ") + ";"
}
func nginxHTTPConfigDir(install model.AppInstall) string {
return path.Join(install.GetPath(), nginxModuleConfDir, nginxHTTPConfDir)
}
func nginxHTTPConfigFileName(order int, name string) string {
return fmt.Sprintf("%s%04d-%s.conf", nginxHTTPConfigPrefix, order, nginxModulePathName(name))
}
// renderNginxHTTPConfig builds the content of a managed http.d file.
func renderNginxHTTPConfig(directives []nginxHTTPDirective) []byte {
var content strings.Builder
content.WriteString(nginxHTTPConfigHeader)
for _, directive := range directives {
content.WriteString(directive.render())
content.WriteString("\n")
}
return []byte(content.String())
}
var nginxHTTPDirectiveRe = regexp.MustCompile(`^[ \t]*([a-z_][a-z0-9_]*)[ \t]+([^;]*);[ \t]*$`)
// readNginxHTTPDirectives parses a managed file back into directive values.
// A missing or unreadable file yields no directives, which makes callers fall
// back to their defaults.
func readNginxHTTPDirectives(filePath string) map[string][]string {
content, err := os.ReadFile(filePath)
if err != nil {
return nil
}
directives := make(map[string][]string)
for _, line := range strings.Split(string(content), "\n") {
match := nginxHTTPDirectiveRe.FindStringSubmatch(line)
if match == nil {
continue
}
directives[match[1]] = strings.Fields(match[2])
}
return directives
}
// snapshotManagedNginxHTTPConfigs captures every managed file so a failed
// nginx -t can be rolled back.
func snapshotManagedNginxHTTPConfigs(configDir string) (nginxModuleConfigSnapshot, error) {
snapshot := make(nginxModuleConfigSnapshot)
entries, err := os.ReadDir(configDir)
if err != nil {
if os.IsNotExist(err) {
return snapshot, nil
}
return nil, err
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), nginxHTTPConfigPrefix) {
continue
}
content, readErr := os.ReadFile(path.Join(configDir, entry.Name()))
if readErr != nil {
return nil, readErr
}
snapshot[entry.Name()] = content
}
return snapshot, nil
}
// applyManagedNginxHTTPConfigs writes the desired managed files and removes
// managed files that are no longer wanted. Files not carrying the managed
// prefix are never touched.
func applyManagedNginxHTTPConfigs(configDir string, desired map[string][]byte) error {
if err := os.MkdirAll(configDir, constant.DirPerm); err != nil {
return err
}
entries, err := os.ReadDir(configDir)
if err != nil {
return err
}
names := make([]string, 0, len(desired))
for fileName := range desired {
names = append(names, fileName)
}
sort.Strings(names)
for _, fileName := range names {
tmpPath := path.Join(configDir, "."+fileName+".tmp")
if err = os.WriteFile(tmpPath, desired[fileName], constant.FilePerm); err != nil {
return err
}
if err = os.Rename(tmpPath, path.Join(configDir, fileName)); err != nil {
return err
}
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), nginxHTTPConfigPrefix) {
continue
}
if _, ok := desired[entry.Name()]; !ok {
if err = os.Remove(path.Join(configDir, entry.Name())); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}
+176
View File
@@ -0,0 +1,176 @@
package service
import (
"strings"
"testing"
)
const plainNginxConf = `user root;
worker_processes auto;
include /usr/local/openresty/nginx/conf/modules-enabled/*.conf;
events {
use epoll;
}
http {
include mime.types;
default_type application/octet-stream;
gzip on;
gzip_comp_level 5;
limit_conn_zone $binary_remote_addr zone=perip:10m;
include /usr/local/openresty/nginx/conf/conf.d/*.conf;
include /usr/local/openresty/nginx/conf/default/*.conf;
}
`
func TestInsertNginxHTTPIncludeBeforeConfD(t *testing.T) {
got, err := insertNginxHTTPInclude(plainNginxConf)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, " "+nginxHTTPIncludeDirective) {
t.Fatalf("include not inserted with matching indent:\n%s", got)
}
// Ordering is the point of the insertion site: panel defaults must be
// evaluated before per-site configuration.
httpIdx := strings.Index(got, "conf/http.d/*.conf")
confDIdx := strings.Index(got, "conf/conf.d/*.conf")
if httpIdx < 0 || confDIdx < 0 || httpIdx > confDIdx {
t.Fatalf("http.d must be included before conf.d (http.d=%d conf.d=%d)", httpIdx, confDIdx)
}
// The rest of the file must be untouched.
stripped := strings.Replace(got, " "+nginxHTTPIncludeDirective+"\n", "", 1)
if stripped != plainNginxConf {
t.Fatal("insertion altered content outside the inserted line")
}
}
func TestInsertNginxHTTPIncludeIsIdempotent(t *testing.T) {
once, err := insertNginxHTTPInclude(plainNginxConf)
if err != nil {
t.Fatal(err)
}
twice, err := insertNginxHTTPInclude(once)
if err != nil {
t.Fatal(err)
}
if twice != once {
t.Fatal("a second insertion must be a no-op")
}
}
func TestInsertNginxHTTPIncludeFallsBackToHTTPBlock(t *testing.T) {
content := strings.Replace(plainNginxConf,
" include /usr/local/openresty/nginx/conf/conf.d/*.conf;\n", "", 1)
got, err := insertNginxHTTPInclude(content)
if err != nil {
t.Fatal(err)
}
httpIdx := strings.Index(got, "http {")
incIdx := strings.Index(got, nginxHTTPIncludeDirective)
if incIdx < 0 || incIdx < httpIdx {
t.Fatalf("include should land inside the http block:\n%s", got)
}
// Indented one level deeper than the http keyword.
if !strings.Contains(got, " "+nginxHTTPIncludeDirective) {
t.Errorf("fallback indentation is wrong:\n%s", got)
}
}
func TestInsertNginxHTTPIncludeRejectsConfigWithoutHTTPBlock(t *testing.T) {
if _, err := insertNginxHTTPInclude("events {}\n"); err == nil {
t.Fatal("a config without an http block must be rejected so callers can degrade")
}
}
func TestInsertNginxHTTPIncludeIgnoresCommentedIncludes(t *testing.T) {
commented := strings.Replace(plainNginxConf,
" include /usr/local/openresty/nginx/conf/conf.d/*.conf;",
" # include /usr/local/openresty/nginx/conf/conf.d/*.conf;", 1)
got, err := insertNginxHTTPInclude(commented)
if err != nil {
t.Fatal(err)
}
// The commented conf.d line is not a valid anchor; the fallback must win.
if strings.Index(got, nginxHTTPIncludeDirective) < strings.Index(got, "http {") {
t.Fatal("a commented include must not be used as the anchor")
}
}
func TestInsertNginxHTTPIncludeHandlesCRLF(t *testing.T) {
crlf := strings.ReplaceAll(plainNginxConf, "\n", "\r\n")
got, err := insertNginxHTTPInclude(crlf)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, nginxHTTPIncludeDirective) {
t.Fatal("include missing on a CRLF file")
}
}
func TestNginxHTTPIncludeRe(t *testing.T) {
cases := []struct {
name string
content string
want bool
}{
{"present", plainNginxConf + " " + nginxHTTPIncludeDirective + "\n", true},
{"absent", plainNginxConf, false},
{"commented out", "# " + nginxHTTPIncludeDirective, false},
// nginx accepts quoted paths, and a hand-written or legacy installer
// may use them; a quoted include must count as present.
{"quoted absolute path", `include "/usr/local/openresty/nginx/conf/http.d/*.conf";`, true},
{"quoted with extra whitespace", ` include "/usr/local/openresty/nginx/conf/http.d/*.conf" ;`, true},
{"relative path form", ` include http.d/*.conf;`, true},
{"a different directory does not count", ` include /usr/local/openresty/nginx/conf/conf.d/*.conf;`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := nginxHTTPIncludeRe.MatchString(tc.content); got != tc.want {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
// The anchor for insertion must tolerate the same variants, or a config with
// a quoted conf.d include would take the http-block fallback for no reason.
func TestInsertNginxHTTPIncludeWithQuotedConfDAnchor(t *testing.T) {
quoted := strings.Replace(plainNginxConf,
" include /usr/local/openresty/nginx/conf/conf.d/*.conf;",
` include "/usr/local/openresty/nginx/conf/conf.d/*.conf";`, 1)
got, err := insertNginxHTTPInclude(quoted)
if err != nil {
t.Fatal(err)
}
httpIdx := strings.Index(got, "conf/http.d/*.conf")
confDIdx := strings.Index(got, "conf/conf.d/*.conf")
if httpIdx < 0 || confDIdx < 0 || httpIdx > confDIdx {
t.Fatalf("quoted anchor not used; include misplaced:\n%s", got)
}
}
// A CRLF file must keep its own line endings after insertion.
func TestInsertNginxHTTPIncludeKeepsCRLFStyle(t *testing.T) {
crlf := strings.ReplaceAll(plainNginxConf, "\n", "\r\n")
got, err := insertNginxHTTPInclude(crlf)
if err != nil {
t.Fatal(err)
}
idx := strings.Index(got, nginxHTTPIncludeDirective)
if idx < 0 {
t.Fatal("include missing")
}
if got[idx-1] == '\n' || (idx >= 2 && got[idx-2:idx] != "\r\n" && got[idx-1] != ' ') {
// The inserted line must end with \r\n like the rest of the file.
}
end := idx + len(nginxHTTPIncludeDirective)
if end+2 > len(got) || got[end:end+2] != "\r\n" {
t.Fatalf("inserted line does not end with CRLF: %q", got[end:end+4])
}
}
+124 -19
View File
@@ -18,6 +18,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
@@ -164,6 +165,51 @@ func nginxModuleDynamicSupported(install model.AppInstall) bool {
fileOp.Stat(path.Join(buildPath, nginxModuleCatalogFile))
}
// nginxModuleStaticSupported reports whether the install can recompile its own
// OpenResty image, which is what a static module build needs. Versions before
// dynamic modules existed ship a compose file with a build section and the
// sources under build/; the oldest ones only reference a prebuilt image and
// cannot compile anything.
func nginxModuleStaticSupported(install model.AppInstall) bool {
if !files.NewFileOp().Stat(path.Join(install.GetPath(), nginxModuleBuildDir, "Dockerfile")) {
return false
}
envStr, err := coverEnvJsonToStr(install.Env)
if err != nil {
return false
}
project, err := dockerUtils.GetComposeProject(install.Name, install.GetPath(),
[]byte(install.DockerCompose), []byte(envStr), true)
if err != nil {
return false
}
for _, service := range project.AllServices() {
if service.Build != nil {
return true
}
}
return false
}
// defaultNginxModuleBuildMode picks the mode an install can actually perform.
//
// Module state written before build modes existed carries no buildMode at all.
// Rejecting it would fail loadNginxModules, and with it every module operation
// and the upgrade itself, so the value is inferred from what the install can
// do rather than assumed.
func defaultNginxModuleBuildMode(install model.AppInstall) string {
if nginxModuleDynamicSupported(install) {
return nginxModuleBuildDynamic
}
if nginxModuleStaticSupported(install) {
return nginxModuleBuildStatic
}
// Neither builder is available. Dynamic keeps the module inert instead of
// triggering an image rebuild that cannot succeed; the build itself still
// reports the missing capability.
return nginxModuleBuildDynamic
}
func syncNginxModuleBuilder(detailBuildDir, installBuildDir string) error {
sourcePath := path.Join(detailBuildDir, nginxModuleBuilderFile)
targetPath := path.Join(installBuildDir, nginxModuleBuilderFile)
@@ -654,10 +700,56 @@ func reconcileDynamicNginxModuleConfig(install model.AppInstall, modules []dto.N
return fmt.Errorf("validate combined dynamic module configuration: %w", err)
}
}
if err = applyManagedNginxModuleConfigs(configDir, desired); err != nil {
// Runtime directives live in http.d because load_module is main-context
// while directives such as "brotli on" are http-context. Both sets are
// written before nginx -t runs, so nginx only ever observes the final,
// consistent state; on failure both are rolled back together.
//
// The include that loads http.d is inserted on demand: only when a module
// actually needs runtime configuration. An install whose nginx.conf cannot
// be edited safely keeps the previous behaviour — the module loads but the
// runtime directives are skipped — rather than failing the operation.
httpConfigDir := nginxHTTPConfigDir(install)
desiredHTTP := desiredNginxModuleRuntimeConfigs(install, modules, target)
httpActive := nginxHTTPIncludePresent(install)
var nginxConfSnapshot []byte
if len(desiredHTTP) > 0 && !httpActive {
active, confSnapshot, includeErr := ensureNginxHTTPIncludeActive(install)
if includeErr != nil {
global.LOG.Warnf("cannot insert the http.d include into nginx.conf, skipping runtime directives: %v", includeErr)
desiredHTTP = nil
} else {
httpActive = active
nginxConfSnapshot = confSnapshot
}
}
var httpSnapshot nginxModuleConfigSnapshot
if httpActive {
if httpSnapshot, err = snapshotManagedNginxHTTPConfigs(httpConfigDir); err != nil {
return err
}
}
restore := func() {
_ = applyManagedNginxModuleConfigs(configDir, snapshot)
if httpActive {
_ = applyManagedNginxHTTPConfigs(httpConfigDir, httpSnapshot)
}
if nginxConfSnapshot != nil {
_ = os.WriteFile(nginxMainConfigPath(install), nginxConfSnapshot, constant.FilePerm)
}
}
if err = applyManagedNginxModuleConfigs(configDir, desired); err != nil {
restore()
return err
}
if httpActive {
if err = applyManagedNginxHTTPConfigs(httpConfigDir, desiredHTTP); err != nil {
restore()
return err
}
}
if !reload {
return nil
}
@@ -666,11 +758,11 @@ func reconcileDynamicNginxModuleConfig(install model.AppInstall, modules []dto.N
return nil
}
if err = opNginx(install.ContainerName, constant.NginxCheck); err != nil {
_ = applyManagedNginxModuleConfigs(configDir, snapshot)
restore()
return err
}
if err = opNginx(install.ContainerName, constant.NginxReload); err != nil {
_ = applyManagedNginxModuleConfigs(configDir, snapshot)
restore()
return err
}
return nil
@@ -722,6 +814,14 @@ func applyManagedNginxModuleConfigs(configDir string, desired map[string][]byte)
return nil
}
// hasEnabledStaticNginxModules reports whether a full image rebuild is needed.
//
// Module state is the only input on purpose. RESTY_CONFIG_OPTIONS_MORE in .env
// is derived state: configureStaticNginxModules rewrites it from the modules
// below, and every build path calls that function before building. Treating a
// leftover value as a reason to rebuild would start a full recompile that
// configureStaticNginxModules has already reduced to an empty option list, so
// the rebuild could only reproduce the image it started from.
func hasEnabledStaticNginxModules(modules []dto.NginxModule) bool {
for _, module := range modules {
normalizeNginxModule(&module)
@@ -732,17 +832,6 @@ func hasEnabledStaticNginxModules(modules []dto.NginxModule) bool {
return false
}
func staticNginxBuildRequired(install model.AppInstall, modules []dto.NginxModule) bool {
if hasEnabledStaticNginxModules(modules) {
return true
}
envs, err := gotenv.Read(install.GetEnvPath())
if err != nil {
return false
}
return strings.TrimSpace(envs["RESTY_CONFIG_OPTIONS_MORE"]) != ""
}
func configureStaticNginxModules(install model.AppInstall, modules []dto.NginxModule, mirror string) error {
buildPath := path.Join(install.GetPath(), nginxModuleBuildDir)
var params, packages []string
@@ -807,11 +896,17 @@ func executeNginxModuleBuild(install model.AppInstall, reqModules []string, forc
if err != nil {
return err
}
staticBuild := staticNginxBuildRequired(install, modules)
if !staticBuild && hasDynamicNginxModuleBuildTask(modules, reqModules) {
if !nginxModuleDynamicSupported(install) {
return errors.New("the installed OpenResty version does not support dynamic module builds")
}
// Only the module list decides this. A leftover RESTY_CONFIG_OPTIONS_MORE
// used to force the static path here, which meant a full image rebuild for
// an install that has no static module left to compile.
staticBuild := hasEnabledStaticNginxModules(modules)
if !staticBuild && hasDynamicNginxModuleBuildTask(modules, reqModules) && !nginxModuleDynamicSupported(install) {
// The catalog and the builder have always shipped together, and an
// install missing the catalog fails to load its module state before
// this point, so this branch is a guard rather than a real path. Keep
// the error actionable instead of faking a build the state machine
// cannot record.
return buserr.New("ErrModuleBuildUnsupported")
}
if staticBuild {
return executeStaticNginxModuleBuild(install, modules, mirror, force, parentTask)
@@ -886,7 +981,17 @@ func loadNginxModulesWithCatalog(install model.AppInstall, catalogPath string) (
Builds: state.Builds, LastError: state.LastError,
})
}
// Catalog entries always declare a mode; state written before build modes
// existed does not. Fill the gap from the install's capabilities so an
// upgrade from such a version can still read its own module state.
fallbackMode := ""
for i := range modules {
if modules[i].BuildMode == "" {
if fallbackMode == "" {
fallbackMode = defaultNginxModuleBuildMode(install)
}
modules[i].BuildMode = fallbackMode
}
if err = validateNginxModuleBuildMode(modules[i]); err != nil {
return nil, err
}
+483
View File
@@ -0,0 +1,483 @@
package service
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
// nginxCompressibleTypes is shared by gzip_types and brotli_types so both
// encoders cover the same content. Already compressed formats (images other
// than SVG, woff/woff2, archives, media) are deliberately excluded:
// recompressing them costs CPU and usually grows the payload.
var nginxCompressibleTypes = []string{
"text/plain",
"text/css",
"text/xml",
"text/javascript",
"application/json",
"application/ld+json",
"application/javascript",
"application/x-javascript",
"application/xml",
"application/xhtml+xml",
"application/rss+xml",
"application/atom+xml",
"application/wasm",
"image/svg+xml",
"font/ttf",
"font/otf",
}
// nginxModuleRuntimeDefaults maps a module to the http-context directives that
// make it actually do something once loaded. Without these, enabling a module
// only emits load_module, leaving it loaded but inert.
//
// brotli_static is intentionally omitted: nginx does not verify that a .br
// file is newer than its source, so a stale artifact would be served
// indefinitely with no error.
var nginxModuleRuntimeDefaults = map[string][]nginxHTTPDirective{
"ngx_brotli": {
{Name: "brotli", Params: []string{"on"}},
// Brotli level 5 reaches roughly gzip level 9 ratio at a fraction of
// the cost. The nginx default of 6 is tuned for static assets and is
// too expensive for dynamic responses.
{Name: "brotli_comp_level", Params: []string{"5"}},
{Name: "brotli_min_length", Params: []string{"1k"}},
{Name: "brotli_types", Params: nginxCompressibleTypes},
},
}
// nginxModuleRuntimeLoadOrder keeps managed file names stable and ordered
// independently of the module load order used for load_module.
var nginxModuleRuntimeLoadOrder = map[string]int{
"ngx_brotli": 100,
}
func nginxModuleRuntimeOrder(name string) int {
if order, ok := nginxModuleRuntimeLoadOrder[name]; ok {
return order
}
return 900
}
// desiredNginxModuleRuntimeConfigs renders the managed http.d files for every
// enabled module that has a ready build and known runtime defaults.
//
// Values the user changed through the compression settings page are read back
// from the current managed file, so reconciling after an unrelated module
// change does not silently reset them to the defaults.
//
// A module the user already configured by hand in nginx.conf is skipped
// entirely. Emitting the same directive from an included file would make nginx
// reject the configuration as a duplicate, so their setup is left as the only
// definition.
func desiredNginxModuleRuntimeConfigs(install model.AppInstall, modules []dto.NginxModule, target dto.NginxModuleTarget) map[string][]byte {
desired := make(map[string][]byte)
for _, module := range modules {
normalizeNginxModule(&module)
// A custom module that happens to share a built-in name must not pick
// up the built-in's runtime defaults; the table is for catalog modules.
if module.Custom {
continue
}
directives, ok := nginxModuleRuntimeDefaults[module.Name]
if !ok || !module.Enable {
continue
}
if !nginxModuleRuntimeReady(module, target) {
continue
}
if nginxModuleConfiguredByUser(install, module.Name) {
continue
}
fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(module.Name), module.Name)
current := readNginxHTTPDirectives(path.Join(nginxHTTPConfigDir(install), fileName))
desired[fileName] = renderNginxHTTPConfig(mergeNginxRuntimeDirectives(directives, current))
}
return desired
}
// nginxModuleConfiguredByUser reports whether the user already manages any of
// the module's directives by hand.
//
// Users who enabled brotli before the panel managed it did so by editing
// nginx.conf or a file it includes. That definition has to keep winning: it is
// the one nginx has been running with, and adding a second one from http.d
// would break the configuration outright.
//
// Any brotli* directive counts, not just the primary one. A user who only
// tuned brotli_comp_level has still taken ownership of the block, and nginx
// allows the same directive at http and server scope, so a site-scoped value
// must suppress the managed one too.
func nginxModuleConfiguredByUser(install model.AppInstall, moduleName string) bool {
if _, ok := nginxModuleRuntimeDefaults[moduleName]; !ok {
return false
}
for _, filePath := range nginxModuleUserConfigPaths(install) {
content, err := os.ReadFile(filePath)
if err != nil {
continue
}
if nginxModuleUserDirectiveRe.MatchString(string(content)) {
return true
}
}
return false
}
// nginxModuleUserDirectiveRe matches any active (non-commented) brotli*
// directive at the start of a line, wherever it was written.
var nginxModuleUserDirectiveRe = regexp.MustCompile(`(?m)^[ \t]*brotli[a-z_]*[ \t]+[^;\n]*;`)
// nginxModuleUserConfigPaths lists the files that may carry a user's brotli
// configuration: the main config and the http-scope files it includes. The
// stream include is skipped on purpose — brotli is an http module and has no
// business there.
func nginxModuleUserConfigPaths(install model.AppInstall) []string {
return nginxModuleUserConfigPathsWithSiteDir(install, GetWebSiteRootDir())
}
// nginxModuleUserConfigPathsWithSiteDir is the testable core: the site conf
// directory is injected so unit tests do not need the settings database.
func nginxModuleUserConfigPathsWithSiteDir(install model.AppInstall, siteDir string) []string {
paths := []string{nginxMainConfigPath(install)}
paths = append(paths, globConfFiles(path.Join(siteDir, "conf.d"))...)
paths = append(paths, globConfFiles(path.Join(install.GetPath(), nginxModuleConfDir, "default"))...)
return paths
}
func globConfFiles(dir string) []string {
matches, err := filepath.Glob(path.Join(dir, "*.conf"))
if err != nil {
return nil
}
return matches
}
// nginxUserDirectivePattern matches a directive the user wrote in nginx.conf,
// capturing its indentation so a rewrite can keep the line's shape. Leading
// whitespace only, so a commented-out line never matches.
func nginxUserDirectivePattern(name string) *regexp.Regexp {
return regexp.MustCompile(`(?m)^([ \t]*)` + regexp.QuoteMeta(name) + `[ \t]+[^;\n]*;`)
}
// nginxConfigDefinesDirective reports whether a directive is set anywhere in
// the file, ignoring commented-out lines.
func nginxConfigDefinesDirective(content, name string) bool {
return nginxUserDirectivePattern(name).MatchString(content)
}
// mergeNginxRuntimeDirectives keeps the declared directive set and ordering
// while preferring values already present in the managed file.
func mergeNginxRuntimeDirectives(defaults []nginxHTTPDirective, current map[string][]string) []nginxHTTPDirective {
if len(current) == 0 {
return defaults
}
merged := make([]nginxHTTPDirective, 0, len(defaults))
for _, directive := range defaults {
if params, ok := current[directive.Name]; ok && len(params) > 0 {
directive.Params = params
}
merged = append(merged, directive)
}
return merged
}
// nginxBrotliModuleName is the catalog name of the brotli module.
const nginxBrotliModuleName = "ngx_brotli"
// getNginxBrotliParams reports the brotli settings currently in effect, and
// where they come from.
//
// Brotli is normally served from the managed http.d file instead of
// nginx.conf, so the directives can be removed together with the module. When
// the module is disabled the declared defaults are returned, which lets the
// settings page show what would be applied once it is enabled.
//
// If the user configured brotli anywhere nginx loads it from, those values
// are reported instead and ManagedExternally is set. Showing the managed
// defaults there would misrepresent what the server is actually running, and
// the panel must not write a second copy.
func getNginxBrotliParams() (*response.NginxBrotliRes, error) {
install, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return nil, err
}
managedExternally := nginxModuleConfiguredByUser(install, nginxBrotliModuleName)
var current map[string][]string
if managedExternally {
current = readNginxUserBrotliDirectives(install)
} else {
fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName)
current = readNginxHTTPDirectives(path.Join(nginxHTTPConfigDir(install), fileName))
}
res := &response.NginxBrotliRes{
ManagedExternally: managedExternally,
// Without the include, values the panel would write would never reach
// nginx, so they are reported as unavailable rather than shown as if
// they were in effect.
ManagedUnavailable: !managedExternally && !nginxHTTPIncludePresent(install),
}
for _, directive := range mergeNginxRuntimeDirectives(nginxModuleRuntimeDefaults[nginxBrotliModuleName], current) {
res.Params = append(res.Params, response.NginxParam{Name: directive.Name, Params: directive.Params})
}
return res, nil
}
// readNginxUserBrotliDirectives collects the brotli directives the user wrote
// in any of the files nginx loads them from.
func readNginxUserBrotliDirectives(install model.AppInstall) map[string][]string {
directives := make(map[string][]string)
for _, filePath := range nginxModuleUserConfigPaths(install) {
content, err := os.ReadFile(filePath)
if err != nil {
continue
}
for _, name := range dto.BrotliKeys {
pattern := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(name) + `[ \t]+([^;\n]*);`)
if match := pattern.FindStringSubmatch(string(content)); match != nil {
if _, exists := directives[name]; !exists {
directives[name] = strings.Fields(strings.TrimSpace(match[1]))
}
}
}
}
return directives
}
// nginxBrotliValueRe whitelists what a brotli value may contain. The values
// are written into nginx.conf and the managed files verbatim; rejecting
// anything outside this set blocks both directive injection (`;`, newline,
// braces, quotes) and the `$` group-reference expansion of
// regexp.ReplaceAllString, which the in-place rewrite uses.
var nginxBrotliValueRe = regexp.MustCompile(`^[a-zA-Z0-9._+\-/:* ]+$`)
// validateNginxBrotliValues rejects any value outside the whitelist. The UI
// only sends on/off, numbers and sizes, but the endpoint is reachable
// directly.
func validateNginxBrotliValues(values map[string][]string) error {
for name, params := range values {
for _, param := range params {
if !nginxBrotliValueRe.MatchString(param) {
return buserr.WithDetail("ErrInvalidParams", fmt.Sprintf("invalid value for %s", name), nil)
}
}
}
return nil
}
// updateNginxBrotliParams persists brotli settings to the managed http.d file.
//
// Writing is refused unless the module is enabled and built: the directives
// would reference a module that is not loaded and nginx would fail to start.
func updateNginxBrotliParams(params []dto.NginxParam) error {
install, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
}
modules, err := loadNginxModules(install)
if err != nil {
return err
}
values := make(map[string][]string, len(params))
for _, param := range params {
values[param.Name] = param.Params
}
if err = validateNginxBrotliValues(values); err != nil {
return err
}
for i := range modules {
if modules[i].Name != nginxBrotliModuleName {
continue
}
if !modules[i].Enable {
return buserr.New("ErrBrotliDisabled")
}
// The user configured brotli in nginx.conf before the panel managed
// it. Update those lines in place: writing a managed file as well
// would define every directive twice and nginx would refuse to start.
if nginxModuleConfiguredByUser(install, nginxBrotliModuleName) {
return updateUserNginxBrotliParams(install, values)
}
// A managed write needs the include. Installations missing it are
// upgraded in place here; when nginx.conf cannot be edited safely the
// write is refused with an actionable error instead of writing values
// nginx would never load.
if !nginxHTTPIncludePresent(install) {
configPath := nginxMainConfigPath(install)
content, readErr := os.ReadFile(configPath)
if readErr != nil {
return readErr
}
updated, insErr := insertNginxHTTPInclude(string(content))
if insErr != nil {
return buserr.New("ErrBrotliUnsupported")
}
if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil {
return err
}
if err = os.MkdirAll(nginxHTTPConfigDir(install), constant.DirPerm); err != nil {
return err
}
if err = nginxCheckAndReload(string(content), configPath, install.ContainerName); err != nil {
return err
}
}
fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName)
configDir := nginxHTTPConfigDir(install)
snapshot, snapErr := snapshotManagedNginxHTTPConfigs(configDir)
if snapErr != nil {
return snapErr
}
merged := mergeNginxRuntimeDirectives(nginxModuleRuntimeDefaults[nginxBrotliModuleName], values)
desired := map[string][]byte{fileName: renderNginxHTTPConfig(merged)}
for name, content := range snapshot {
if name != fileName {
desired[name] = content
}
}
if err = applyManagedNginxHTTPConfigs(configDir, desired); err != nil {
_ = applyManagedNginxHTTPConfigs(configDir, snapshot)
return err
}
if err = opNginx(install.ContainerName, constant.NginxCheck); err != nil {
_ = applyManagedNginxHTTPConfigs(configDir, snapshot)
return err
}
if err = opNginx(install.ContainerName, constant.NginxReload); err != nil {
_ = applyManagedNginxHTTPConfigs(configDir, snapshot)
return err
}
// The directory is bind-mounted read-only and the include is a glob: a
// missing mount or an unrecognised include lets nginx -t pass while
// loading nothing. Read the effective configuration back instead of
// trusting the files we wrote.
if err = assertNginxBrotliActive(install.ContainerName); err != nil {
_ = applyManagedNginxHTTPConfigs(configDir, snapshot)
return buserr.New("ErrBrotliUnsupported")
}
return nil
}
return buserr.New("ErrBrotliDisabled")
}
// assertNginxBrotliActive confirms the managed brotli directives are in the
// running server's effective configuration. It is the only check that catches
// a bind mount that never reached the container or an include variant the
// detection missed — both pass nginx -t and reload silently.
func assertNginxBrotliActive(containerName string) error {
out, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout(
"docker", "exec", "-i", containerName, "nginx", "-T")
if err != nil {
return err
}
if !nginxModuleUserDirectiveRe.MatchString(out) {
return errors.New("brotli directives are not in the effective nginx configuration")
}
return nil
}
// updateUserNginxBrotliParams rewrites the brotli directives the user wrote
// into nginx.conf, in place.
//
// Only the values change: each directive keeps its original line and
// indentation, and every other line is untouched, so a hand-maintained config
// survives an edit from the settings page. Directives the user did not write
// are not introduced, since the panel cannot know where they intended them.
//
// A managed file can still be on disk when the panel managed brotli before
// the user wrote their own directives. Leaving it behind would make every
// directive duplicate once the user's config is touched, so it is removed
// first and rolled back together with the config on a failed nginx -t.
func updateUserNginxBrotliParams(install model.AppInstall, values map[string][]string) error {
configPath := nginxMainConfigPath(install)
content, err := os.ReadFile(configPath)
if err != nil {
return err
}
configDir := nginxHTTPConfigDir(install)
httpSnapshot, snapErr := snapshotManagedNginxHTTPConfigs(configDir)
if snapErr != nil {
return snapErr
}
managedFile := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName)
if _, stale := httpSnapshot[managedFile]; stale {
remaining := make(map[string][]byte, len(httpSnapshot))
for name, fileContent := range httpSnapshot {
if name != managedFile {
remaining[name] = fileContent
}
}
if err = applyManagedNginxHTTPConfigs(configDir, remaining); err != nil {
return err
}
}
restore := func() {
_ = writeNginxFileAtomic(configPath, content)
_ = applyManagedNginxHTTPConfigs(configDir, httpSnapshot)
}
updated := string(content)
for _, name := range dto.BrotliKeys {
params, ok := values[name]
if !ok || len(params) == 0 {
continue
}
pattern := nginxUserDirectivePattern(name)
if !pattern.MatchString(updated) {
continue
}
replacement := "${1}" + name + " " + strings.Join(params, " ") + ";"
updated = pattern.ReplaceAllString(updated, replacement)
}
if updated == string(content) {
return nil
}
if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil {
restore()
return err
}
if err = opNginx(install.ContainerName, constant.NginxCheck); err != nil {
restore()
return err
}
if err = opNginx(install.ContainerName, constant.NginxReload); err != nil {
restore()
return err
}
return nil
}
// nginxModuleRuntimeReady reports whether the module is actually usable.
//
// Dynamic modules need a ready build for the current target, otherwise the
// .so is missing and nginx would reject the directives. Static modules are
// compiled into the binary and carry no artifacts, so an enabled static
// module is considered ready. This rests on a data premise: the catalog only
// declares a module static when the image ships it. Checking for a build
// record instead would be wrong here — reconcile runs inside the static build
// flow, before the record for the build in progress exists, and would drop
// the runtime configuration of the module that was just compiled in.
func nginxModuleRuntimeReady(module dto.NginxModule, target dto.NginxModuleTarget) bool {
if module.BuildMode == nginxModuleBuildStatic {
return true
}
build := findCurrentNginxModuleBuild(module, target)
if build == nil || build.Status != nginxModuleStatusReady {
build = findLatestNginxModuleBuild(module, target)
}
return build != nil && build.Status == nginxModuleStatusReady
}
@@ -0,0 +1,231 @@
package service
import (
"os"
"path"
"strings"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
)
const userBrotliConf = `user root;
worker_processes auto;
include /usr/local/openresty/nginx/conf/modules-enabled/*.conf;
events { use epoll; }
http {
include mime.types;
default_type application/octet-stream;
gzip on;
gzip_comp_level 5;
# enabled by hand, long before the panel managed it
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json;
include /usr/local/openresty/nginx/conf/http.d/*.conf;
include /usr/local/openresty/nginx/conf/conf.d/*.conf;
}
`
func TestNginxConfigDefinesDirective(t *testing.T) {
cases := []struct {
name string
content string
want bool
}{
{"directive present", userBrotliConf, true},
{"absent", strings.Replace(userBrotliConf, " brotli on;\n", "", 1), false},
{
name: "commented out does not count",
content: strings.Replace(userBrotliConf, " brotli on;", " # brotli on;", 1),
want: false,
},
{
name: "a longer directive name is not a match",
content: "http {\n brotli_comp_level 6;\n}\n",
want: false,
},
{
name: "indentation does not matter",
content: "http {\n\t\tbrotli on;\n}\n",
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := nginxConfigDefinesDirective(tc.content, "brotli"); got != tc.want {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
// The detection must catch any brotli* directive, in any file nginx loads it
// from, not only the primary directive in nginx.conf.
func TestNginxModuleUserDirectiveRe(t *testing.T) {
cases := []struct {
name string
content string
want bool
}{
{"primary directive", "http {\n brotli on;\n}", true},
{"a tuning directive alone", "server {\n brotli_comp_level 11;\n}", true},
{"another variant", "http {\n brotli_types text/plain;\n}", true},
{"server scope in a site file", "server {\n listen 80;\n brotli on;\n}", true},
{"commented out does not count", "http {\n # brotli on;\n}", false},
{"indented comment does not count", "http {\n # brotli_comp_level 6;\n}", false},
{"no brotli at all", "http {\n gzip on;\n}", false},
{"a similarly named directive is not a match", "http {\n gzip on;\n}", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := nginxModuleUserDirectiveRe.MatchString(tc.content); got != tc.want {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
// The panel must not emit a managed file for a module the user already
// configured: nginx rejects the same directive defined twice.
func TestUserConfiguredBrotliSuppressesManagedFile(t *testing.T) {
if !nginxModuleUserDirectiveRe.MatchString(userBrotliConf) {
t.Fatal("a hand-written brotli config must be detected")
}
clean := strings.Replace(userBrotliConf, " brotli on;\n", "", 1)
clean = strings.Replace(clean, " brotli_comp_level 6;\n", "", 1)
clean = strings.Replace(clean, " brotli_types text/plain text/css application/json;\n", "", 1)
if nginxModuleUserDirectiveRe.MatchString(clean) {
t.Fatal("a config without brotli must not be treated as user-managed")
}
}
func TestRewriteUserBrotliDirectivesInPlace(t *testing.T) {
// Mirrors updateUserNginxBrotliParams without touching the filesystem.
rewrite := func(content string, values map[string][]string) string {
updated := content
for _, name := range []string{"brotli", "brotli_comp_level", "brotli_min_length", "brotli_types"} {
params, ok := values[name]
if !ok || len(params) == 0 {
continue
}
pattern := nginxUserDirectivePattern(name)
if !pattern.MatchString(updated) {
continue
}
updated = pattern.ReplaceAllString(updated, "${1}"+name+" "+strings.Join(params, " ")+";")
}
return updated
}
got := rewrite(userBrotliConf, map[string][]string{
"brotli": {"off"},
"brotli_comp_level": {"4"},
// brotli_min_length is absent from the user's config and must not be
// introduced: the panel cannot know where they would want it.
"brotli_min_length": {"2k"},
})
if !strings.Contains(got, " brotli off;") {
t.Errorf("value was not updated:\n%s", got)
}
if !strings.Contains(got, " brotli_comp_level 4;") {
t.Errorf("comp level was not updated:\n%s", got)
}
if strings.Contains(got, "brotli_min_length") {
t.Error("a directive the user never wrote must not be added")
}
// Everything else survives, including the comment the parser would drop.
for _, keep := range []string{
"# enabled by hand, long before the panel managed it",
" gzip on;",
" gzip_comp_level 5;",
" brotli_types text/plain text/css application/json;",
"include /usr/local/openresty/nginx/conf/conf.d/*.conf;",
"worker_processes auto;",
} {
if !strings.Contains(got, keep) {
t.Errorf("unrelated line was altered or lost: %s", keep)
}
}
if strings.Count(got, "brotli on;")+strings.Count(got, "brotli off;") != 1 {
t.Error("the directive must remain defined exactly once")
}
}
func TestRewriteUserBrotliPreservesIndentation(t *testing.T) {
content := "http {\n\t\tbrotli on;\n}\n"
pattern := nginxUserDirectivePattern("brotli")
got := pattern.ReplaceAllString(content, "${1}brotli off;")
if !strings.Contains(got, "\t\tbrotli off;") {
t.Errorf("original indentation was not preserved: %q", got)
}
}
// Detection must cover the default/ directory, which is included at http
// scope like conf.d but lives under the install directory, not the site root.
func TestNginxModuleUserConfigPathsCoversDefaultDir(t *testing.T) {
siteDir := t.TempDir()
installRoot := t.TempDir()
installDir := path.Join(installRoot, "openresty", "openresty")
defaultDir := path.Join(installDir, "conf", "default")
if err := os.MkdirAll(defaultDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path.Join(defaultDir, "00.default.conf"), []byte("server {}\n"), 0o644); err != nil {
t.Fatal(err)
}
global.Dir.AppInstallDir = installRoot
install := model.AppInstall{Name: "openresty"}
install.App.Key = constant.AppOpenresty
paths := nginxModuleUserConfigPathsWithSiteDir(install, siteDir)
foundMain, foundDefault := false, false
for _, p := range paths {
if strings.HasSuffix(p, path.Join("conf", "nginx.conf")) {
foundMain = true
}
if strings.HasSuffix(p, path.Join("conf", "default", "00.default.conf")) {
foundDefault = true
}
}
if !foundMain {
t.Error("nginx.conf must be scanned")
}
if !foundDefault {
t.Error("conf/default must be scanned: it is included at http scope")
}
}
// Values outside the whitelist must never reach nginx.conf or the managed
// files: they could inject directives or trigger regexp group expansion.
func TestValidateNginxBrotliValues(t *testing.T) {
valid := map[string][]string{
"brotli": {"on"},
"brotli_comp_level": {"11"},
"brotli_min_length": {"1k"},
"brotli_types": {"text/plain", "application/json", "application/ld+json"},
}
if err := validateNginxBrotliValues(valid); err != nil {
t.Fatalf("legitimate values rejected: %v", err)
}
for name, bad := range map[string]string{
"directive injection": "off; gzip on",
"newline injection": "off\nbrotli off;",
"group reference": "$1",
"brace": "${1}",
"quote": `"on"`,
} {
if err := validateNginxBrotliValues(map[string][]string{"brotli": {bad}}); err == nil {
t.Errorf("%s: %q must be rejected", name, bad)
}
}
}
@@ -0,0 +1,83 @@
package service
import (
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
)
func TestHasEnabledStaticNginxModules(t *testing.T) {
cases := []struct {
name string
modules []dto.NginxModule
want bool
}{
{
name: "an enabled static module requires a rebuild",
modules: []dto.NginxModule{
{Name: "custom", Enable: true, BuildMode: nginxModuleBuildStatic},
},
want: true,
},
{
name: "a disabled static module does not",
modules: []dto.NginxModule{
{Name: "custom", Enable: false, BuildMode: nginxModuleBuildStatic},
},
want: false,
},
{
name: "dynamic modules never require a rebuild",
modules: []dto.NginxModule{
{Name: "ngx_brotli", Enable: true, BuildMode: nginxModuleBuildDynamic},
},
want: false,
},
{
name: "no modules at all",
modules: nil,
want: false,
},
{
name: "one enabled static module among dynamic ones is enough",
modules: []dto.NginxModule{
{Name: "ngx_brotli", Enable: true, BuildMode: nginxModuleBuildDynamic},
{Name: "custom", Enable: true, BuildMode: nginxModuleBuildStatic},
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := hasEnabledStaticNginxModules(tc.modules); got != tc.want {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
// A stale RESTY_CONFIG_OPTIONS_MORE used to force the full-rebuild path even
// with no static module enabled. configureStaticNginxModules derives that value
// from the module list and runs before every build, so the rebuild it triggered
// could only ever reproduce the current image. Module state is now the only
// input; this test pins that down.
func TestStaticRebuildIgnoresLeftoverBuildOptions(t *testing.T) {
modules := []dto.NginxModule{
{Name: "ngx_brotli", Enable: true, BuildMode: nginxModuleBuildDynamic},
}
if hasEnabledStaticNginxModules(modules) {
t.Fatal("dynamic-only modules must not select the static build path")
}
}
// normalizeNginxModule is applied to a copy, so callers keep their entities.
func TestHasEnabledStaticNginxModulesDoesNotMutateInput(t *testing.T) {
modules := []dto.NginxModule{
{Name: "custom", Enable: true, BuildMode: nginxModuleBuildStatic, Packages: []string{"", "libfoo", ""}},
}
_ = hasEnabledStaticNginxModules(modules)
if len(modules[0].Packages) != 3 {
t.Fatalf("input was normalized in place: %v", modules[0].Packages)
}
}
+47 -22
View File
@@ -640,6 +640,12 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
runtime.Version = req.Version
return runtimeRepo.Save(runtime)
}
appDetail, err := getRuntimeUpdateAppDetail(runtime, req)
if err != nil {
return err
}
versionChanged := appDetail.ID != runtime.AppDetailID || appDetail.Version != runtime.Version
req.Version = appDetail.Version
oldImage := runtime.Image
oldEnv := runtime.Env
var hostPorts []string
@@ -659,23 +665,6 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
}
}
appDetail, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID))
if err != nil {
return err
}
app, err := appRepo.GetFirst(repo.WithByID(appDetail.AppId))
if err != nil {
return err
}
fileOp := files.NewFileOp()
appVersionDir := path.Join(global.Dir.AppResourceDir, app.Resource, app.Key, appDetail.Version)
if !fileOp.Stat(appVersionDir) || appDetail.Update {
if err := downloadApp(app, appDetail, nil, nil); err != nil {
return err
}
_ = fileOp.Rename(path.Join(runtime.GetPath(), "run.sh"), path.Join(runtime.GetPath(), "run.sh.bak"))
_ = fileOp.CopyFile(path.Join(appVersionDir, "run.sh"), runtime.GetPath())
}
}
if containerName, ok := req.Params["CONTAINER_NAME"]; ok && containerName != getRuntimeEnv(runtime.Env, "CONTAINER_NAME") {
@@ -685,6 +674,36 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
runtime.ContainerName = containerName.(string)
}
app, err := appRepo.GetFirst(repo.WithByID(appDetail.AppId))
if err != nil {
return err
}
fileOp := files.NewFileOp()
appVersionDir := filepath.Join(app.GetAppResourcePath(), appDetail.Version)
refreshTemplate := !fileOp.Stat(appVersionDir) || appDetail.Update
if err = downloadApp(app, appDetail, nil, nil); err != nil {
return err
}
if versionChanged {
if err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil {
return err
}
} else if refreshTemplate && runtime.Type != constant.RuntimePHP {
if err = fileOp.CopyFile(filepath.Join(appVersionDir, "run.sh"), runtime.GetPath()); err != nil {
return err
}
}
if runtime.Type == constant.RuntimePHP {
composeContent, err := fileOp.GetContent(runtime.GetComposePath())
if err != nil {
return err
}
req.Environments, err = getDockerComposeEnvironments(composeContent)
if err != nil {
return err
}
}
projectDir := path.Join(global.Dir.RuntimeDir, runtime.Type, runtime.Name)
create := request.RuntimeCreate{
Image: req.Image,
@@ -702,19 +721,21 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
ExtraHosts: req.ExtraHosts,
},
}
composeContent, envContent, _, err := handleParams(create, projectDir)
composeContent, envContent, forms, err := handleParams(create, projectDir)
if err != nil {
return err
}
runtime.Remark = req.Remark
runtime.AppDetailID = appDetail.ID
runtime.Version = appDetail.Version
runtime.Env = string(envContent)
runtime.DockerCompose = string(composeContent)
switch runtime.Type {
case constant.RuntimePHP:
runtime.Image = req.Image
runtime.Params = string(forms)
runtime.Status = constant.StatusBuilding
_ = runtimeRepo.Save(runtime)
client, err := docker.NewClient()
if err != nil {
return err
@@ -724,14 +745,18 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
if err != nil {
return err
}
go buildRuntime(runtime, imageID, oldEnv, req.Rebuild)
if err = runtimeRepo.Save(runtime); err != nil {
return err
}
go buildRuntime(runtime, imageID, oldEnv, req.Rebuild || versionChanged)
case constant.RuntimeNode, constant.RuntimeJava, constant.RuntimeGo, constant.RuntimePython, constant.RuntimeDotNet:
runtime.Version = req.Version
runtime.CodeDir = req.CodeDir
runtime.Port = strings.Join(hostPorts, ",")
runtime.Status = constant.StatusReCreating
runtime.ContainerName = req.Params["CONTAINER_NAME"].(string)
_ = runtimeRepo.Save(runtime)
if err = runtimeRepo.Save(runtime); err != nil {
return err
}
go reCreateRuntime(runtime)
}
return nil
+106
View File
@@ -0,0 +1,106 @@
package service
import (
"path/filepath"
"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/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"gopkg.in/yaml.v3"
)
func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) {
current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID))
if err != nil {
return model.AppDetail{}, err
}
if current.ID == 0 {
return model.AppDetail{}, buserr.New("ErrRecordNotFound")
}
opts := []repo.DBOption{appDetailRepo.WithAppId(current.AppId)}
if req.AppDetailID != 0 {
opts = append(opts, repo.WithByID(req.AppDetailID))
} else if req.Version != "" {
opts = append(opts, appDetailRepo.WithVersion(req.Version))
} else {
return current, nil
}
detail, err := appDetailRepo.GetFirst(opts...)
if err != nil {
return model.AppDetail{}, err
}
if detail.ID == 0 || (req.Version != "" && detail.Version != req.Version) {
return model.AppDetail{}, buserr.New("ErrInvalidParams")
}
return detail, nil
}
func updateRuntimeVersionFiles(runtime *model.Runtime, appVersionDir string) error {
fileOp := files.NewFileOp()
template, err := fileOp.GetContent(filepath.Join(appVersionDir, "docker-compose.yml"))
if err != nil {
return err
}
current, err := fileOp.GetContent(runtime.GetComposePath())
if err != nil {
return err
}
composeContent, err := updateRuntimeImageConfig(current, template)
if err != nil {
return err
}
if runtime.Type == constant.RuntimePHP {
if err = fileOp.CopyDir(filepath.Join(appVersionDir, "build"), runtime.GetPath()); err != nil {
return err
}
if err = fileOp.CopyFile(filepath.Join(appVersionDir, "data.yml"), runtime.GetPath()); err != nil {
return err
}
} else {
if err = fileOp.CopyFile(filepath.Join(appVersionDir, "run.sh"), runtime.GetPath()); err != nil {
return err
}
}
return fileOp.SaveFile(runtime.GetComposePath(), string(composeContent), constant.FilePerm)
}
func updateRuntimeImageConfig(current, template []byte) ([]byte, error) {
var currentCompose, templateCompose map[string]interface{}
if err := yaml.Unmarshal(current, &currentCompose); err != nil {
return nil, err
}
if err := yaml.Unmarshal(template, &templateCompose); err != nil {
return nil, err
}
currentServices, ok := currentCompose["services"].(map[string]interface{})
if !ok || len(currentServices) != 1 {
return nil, buserr.New("ErrFileParse")
}
templateServices, ok := templateCompose["services"].(map[string]interface{})
if !ok || len(templateServices) != 1 {
return nil, buserr.New("ErrFileParse")
}
// Keep container settings and mounts; only the image and build definition belong to the version.
for _, currentService := range currentServices {
service, ok := currentService.(map[string]interface{})
if !ok {
return nil, buserr.New("ErrFileParse")
}
for _, templateService := range templateServices {
target, ok := templateService.(map[string]interface{})
if !ok || (target["image"] == nil && target["build"] == nil) {
return nil, buserr.New("ErrFileParse")
}
for _, key := range []string{"image", "build"} {
delete(service, key)
if value, exists := target[key]; exists {
service[key] = value
}
}
}
}
return yaml.Marshal(currentCompose)
}
+40 -12
View File
@@ -838,21 +838,49 @@ func restartRuntime(runtime *model.Runtime) (err error) {
}
func getDockerComposeEnvironments(yml []byte) ([]request.Environment, error) {
var (
composeProject docker.ComposeProject
err error
)
err = yaml.Unmarshal(yml, &composeProject)
if err != nil {
var project struct {
Services yaml.Node `yaml:"services"`
}
if err := yaml.Unmarshal(yml, &project); err != nil {
return nil, err
}
services := &project.Services
if services.Kind == yaml.AliasNode {
services = services.Alias
}
if services.Kind != 0 && services.Kind != yaml.MappingNode {
return nil, fmt.Errorf("unsupported services format")
}
var res []request.Environment
for _, service := range composeProject.Services {
for key, value := range service.Environment.Variables {
res = append(res, request.Environment{
Key: key,
Value: value,
})
// Keep the file order for both services and environment entries.
for i := 1; i < len(services.Content); i += 2 {
var service struct {
Environment yaml.Node `yaml:"environment"`
}
if err := services.Content[i].Decode(&service); err != nil {
return nil, err
}
environment := &service.Environment
if environment.Kind == yaml.AliasNode {
environment = environment.Alias
}
switch environment.Kind {
case yaml.MappingNode:
for j := 0; j < len(environment.Content); j += 2 {
res = append(res, request.Environment{Key: environment.Content[j].Value, Value: environment.Content[j+1].Value})
}
case yaml.SequenceNode:
for _, item := range environment.Content {
var entry string
if err := item.Decode(&entry); err != nil {
return nil, err
}
key, value, _ := strings.Cut(entry, "=")
res = append(res, request.Environment{Key: key, Value: value})
}
case 0:
default:
return nil, fmt.Errorf("unsupported environment format")
}
}
return res, nil
+1 -16
View File
@@ -124,22 +124,7 @@ func (u *SettingService) GetWebsiteDir() string {
}
func (u *SettingService) Update(key, value string) error {
oldValue := constant.FirewallPortWhiteListValue
if key == constant.FirewallPortWhiteList {
if _, err := parseFirewallPortWhiteList(value); err != nil {
return err
}
if val, err := settingRepo.GetValueByKey(key); err == nil {
oldValue = val
}
}
if err := settingRepo.UpdateOrCreate(key, value); err != nil {
return err
}
if key == constant.FirewallPortWhiteList {
return syncFirewallPortWhiteListAfterUpdate(oldValue)
}
return nil
return settingRepo.UpdateOrCreate(key, value)
}
func (u *SettingService) UpdateTerminalAI(req dto.TerminalAIInfo) error {
+116 -65
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"fmt"
@@ -22,6 +23,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/copier"
csvexport "github.com/1Panel-dev/1Panel/agent/utils/csv_export"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/1Panel-dev/1Panel/agent/utils/firewall"
"github.com/1Panel-dev/1Panel/agent/utils/geo"
"github.com/gin-gonic/gin"
@@ -40,6 +42,7 @@ import (
const sshPath = "/etc/ssh/sshd_config"
const defaultSSHPort = "22"
const sshManagedMarker = "# config by 1panel"
const defaultSSHLogDir = "/var/log"
type SSHService struct{}
@@ -216,10 +219,21 @@ func (u *SSHService) Update(req dto.SSHUpdate) error {
return err
}
oldPortValue := strings.Join(loadSSHPortValues(directives), ",")
if req.Key == "Port" {
if err := checkSSHPortAvailability(splitSSHPorts(oldPortValue), splitSSHPorts(req.NewValue)); err != nil {
return err
}
}
if err := updateSSHDirectiveValue(req.Key, req.NewValue, directives); err != nil {
return err
}
if req.Key == "Port" {
if err := updateSystemAccessPortWhitelist(context.Background(), firewall.PortWhitelistTypeSSH, splitSSHPorts(req.NewValue)); err != nil {
if restoreErr := rewriteSSHManagedDirectives(sshPath, "Port", buildSSHDirectiveLines("Port", oldPortValue)); restoreErr != nil {
return fmt.Errorf("save SSH whitelist: %w; restore SSH configuration: %v", err, restoreErr)
}
return err
}
handleSSHPortUpdate(oldPortValue, req.NewValue)
}
@@ -319,18 +333,6 @@ func handleSSHPortUpdate(oldValue, newValue string) {
}
}
removedPorts, err := parseSSHPortsToInts(diffSSHPorts(oldPorts, newPorts))
if err != nil {
global.LOG.Errorf("parse removed ssh ports failed, err: %v", err)
} else {
addedPorts, err := parseSSHPortsToInts(diffSSHPorts(newPorts, oldPorts))
if err != nil {
global.LOG.Errorf("parse added ssh ports failed, err: %v", err)
} else if err := OperateFirewallPort(removedPorts, addedPorts); err != nil {
global.LOG.Errorf("reset firewall rules %s -> %s failed, err: %v", oldValue, newValue, err)
}
}
primaryPort, err := loadPrimarySSHPort(newValue)
if err != nil {
global.LOG.Errorf("load primary ssh port from %s failed, err: %v", newValue, err)
@@ -370,6 +372,24 @@ func diffSSHPorts(left, right []string) []string {
return diff
}
func checkSSHPortAvailability(oldPorts, newPorts []string) error {
for _, port := range diffSSHPorts(newPorts, oldPorts) {
value, err := strconv.Atoi(port)
if err != nil || value < 1 || value > 65535 {
return fmt.Errorf("invalid SSH port %q", port)
}
if common.ScanPort(value) {
return buserr.WithDetail("ErrPortInUsed", value, nil)
}
listener, err := net.Listen("tcp4", ":"+strconv.Itoa(value))
if err != nil {
return buserr.WithDetail("ErrPortInUsed", value, nil)
}
_ = listener.Close()
}
return nil
}
func loadPrimarySSHPort(value string) (int, error) {
ports := splitSSHPorts(value)
if len(ports) == 0 {
@@ -378,18 +398,6 @@ func loadPrimarySSHPort(value string) (int, error) {
return strconv.Atoi(ports[0])
}
func parseSSHPortsToInts(ports []string) ([]int, error) {
var values []int
for _, port := range ports {
value, err := strconv.Atoi(port)
if err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
}
func runWithOptionalSudo(sudo, name string, args ...string) (string, error) {
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(20 * time.Second))
if sudo != "" {
@@ -668,13 +676,11 @@ func isSSHLogFileName(name string) bool {
return false
}
func (u *SSHService) LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (int64, []dto.SSHHistory, error) {
func listSSHLogFiles(baseDir string) ([]sshFileItem, error) {
var fileList []sshFileItem
var data []dto.SSHHistory
baseDir := "/var/log"
fileItems, err := os.ReadDir(baseDir)
if err != nil {
return 0, data, err
return nil, err
}
for _, item := range fileItems {
if item.IsDir() || !isSSHLogFileName(item.Name()) {
@@ -682,7 +688,7 @@ func (u *SSHService) LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (int64, []d
}
info, err := item.Info()
if err != nil {
return 0, data, err
return nil, err
}
if !info.Mode().IsRegular() {
continue
@@ -695,7 +701,15 @@ func (u *SSHService) LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (int64, []d
}
fileList = append(fileList, sshFileItem{Name: itemPath, Year: info.ModTime().Year()})
}
fileList = sortFileList(fileList)
return sortFileList(fileList), nil
}
func (u *SSHService) LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (int64, []dto.SSHHistory, error) {
var data []dto.SSHHistory
fileList, err := listSSHLogFiles(defaultSSHLogDir)
if err != nil {
return 0, data, err
}
filter := ""
if len(req.Info) != 0 {
@@ -742,7 +756,7 @@ func (u *SSHService) LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (int64, []d
}
func (u *SSHService) CleanLog() error {
return cleanSSHLogFiles("/var/log")
return cleanSSHLogFiles(defaultSSHLogDir)
}
func cleanSSHLogFiles(baseDir string) error {
@@ -1290,20 +1304,11 @@ func loadSSHData(
if err != nil {
return datas, 0, 0
}
lines, err := loadSSHLogLines(filePath)
histories, err := loadSSHHistoriesFromFile(filePath, status, filter, startTime, endTime, currentYear, nyc)
if err != nil {
return datas, 0, 0
}
items := collectSSHLogItems(lines, filter, status)
for i := len(items) - 1; i >= 0; i-- {
itemData := items[i].History
if !matchSSHLogStatus(status, itemData.Status) || !checkIsStandard(itemData) {
continue
}
itemData.Date = loadDate(currentYear, itemData.DateStr, nyc)
if !isSSHLogWithinTimeRange(itemData.Date, startTime, endTime) {
continue
}
for _, itemData := range histories {
if successCount+failedCount >= showCountFrom && (showCountTo == -1 || successCount+failedCount < showCountTo) {
itemData.Area, _ = geo.GetIPLocation(getLoc, itemData.Address, common.GetLang(ctx))
datas = append(datas, itemData)
@@ -1317,6 +1322,32 @@ func loadSSHData(
return datas, successCount, failedCount
}
func loadSSHHistoriesFromFile(
filePath, status, filter string,
startTime, endTime time.Time,
currentYear int,
location *time.Location,
) ([]dto.SSHHistory, error) {
lines, err := loadSSHLogLines(filePath)
if err != nil {
return nil, err
}
items := collectSSHLogItems(lines, filter, status)
histories := make([]dto.SSHHistory, 0, len(items))
for i := len(items) - 1; i >= 0; i-- {
itemData := items[i].History
if !matchSSHLogStatus(status, itemData.Status) || !checkIsStandard(itemData) {
continue
}
itemData.Date = loadDate(currentYear, itemData.DateStr, location)
if !isSSHLogWithinTimeRange(itemData.Date, startTime, endTime) {
continue
}
histories = append(histories, itemData)
}
return histories, nil
}
func isSSHLogWithinTimeRange(itemTime, startTime, endTime time.Time) bool {
if startTime.IsZero() || endTime.IsZero() {
return true
@@ -1328,6 +1359,8 @@ func collectSSHLogItems(lines []string, filter, status string) []sshParsedLog {
var items []sshParsedLog
auxiliaryIndex := make(map[string]int)
sessionHasAuthEvent := make(map[string]bool)
authenticatedEndpoints := make(map[string]bool)
matchedTerminalSessions := make(map[string]bool)
for lineIndex, line := range lines {
if !shouldParseSSHLogLine(line, status, filter) {
continue
@@ -1338,11 +1371,32 @@ func collectSSHLogItems(lines []string, filter, status string) []sshParsedLog {
}
item.Index = lineIndex
item.Search = line
endpointKey := loadSSHLogEndpointKey(item.History)
if isSSHAuthEvent(item) && item.SessionKey != "" {
sessionHasAuthEvent[item.SessionKey] = true
delete(matchedTerminalSessions, item.SessionKey)
if endpointKey != "" {
if item.History.Status == constant.StatusSuccess {
authenticatedEndpoints[endpointKey] = true
} else {
delete(authenticatedEndpoints, endpointKey)
}
}
items = append(items, item)
continue
}
if endpointKey != "" && isSSHTerminalEvent(item) && authenticatedEndpoints[endpointKey] {
// A successful authentication and its terminal log can be emitted by
// different sshd processes. Associate only this active connection by
// endpoint, then clear it so a reused client port starts a new session.
delete(authenticatedEndpoints, endpointKey)
matchedTerminalSessions[item.SessionKey] = true
continue
}
if isSSHTerminalEvent(item) && matchedTerminalSessions[item.SessionKey] {
continue
}
delete(matchedTerminalSessions, item.SessionKey)
if index, ok := auxiliaryIndex[item.SessionKey]; ok {
items[index].Search += "\n" + line
continue
@@ -1381,6 +1435,13 @@ func isSSHAuthEvent(item sshParsedLog) bool {
return item.History.Status == constant.StatusSuccess || item.History.AuthMode != ""
}
func isSSHTerminalEvent(item sshParsedLog) bool {
message := item.History.Message
return strings.HasPrefix(message, "Connection closed by ") ||
strings.HasPrefix(message, "Disconnected from ") ||
strings.HasPrefix(message, "Received disconnect from ")
}
func shouldParseSSHLogLine(line, status, filter string) bool {
if !strings.Contains(line, "sshd") {
return false
@@ -1482,18 +1543,27 @@ func parseSSHLogHeader(line string) (sshLogHeader, bool) {
}
func loadSSHLogSessionKey(header sshLogHeader, data dto.SSHHistory, line string) string {
if header.Process == "sshd-session" && data.Address != "" && data.Port != "" {
return data.Address + ":" + data.Port
if header.Process == "sshd-session" {
if endpointKey := loadSSHLogEndpointKey(data); endpointKey != "" {
return endpointKey
}
}
if header.PID != "" {
return "pid:" + header.PID
}
if data.Address != "" && data.Port != "" {
return data.Address + ":" + data.Port
if endpointKey := loadSSHLogEndpointKey(data); endpointKey != "" {
return endpointKey
}
return line
}
func loadSSHLogEndpointKey(data dto.SSHHistory) string {
if data.Address == "" || data.Port == "" {
return ""
}
return net.JoinHostPort(data.Address, data.Port)
}
func normalizeSSHLogDate(dateStr string) string {
if t, err := time.Parse(time.RFC3339Nano, dateStr); err == nil {
return t.Format("2006 Jan 2 15:04:05")
@@ -1688,22 +1758,3 @@ func updateSSHSocketFile(newPort string) error {
_ = controller.HandleRestart("ssh.socket")
return nil
}
func loadSSHPort() string {
port := "22"
sshConf, err := os.ReadFile(sshPath)
if err != nil {
return port
}
lines := strings.Split(string(sshConf), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "Port ") {
portStr := strings.ReplaceAll(line, "Port ", "")
portItem, _ := strconv.Atoi(portStr)
if portItem > 0 && portItem < 65535 {
return portStr
}
}
}
return port
}
+24 -5
View File
@@ -4,17 +4,36 @@ import (
"encoding/json"
"strings"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
)
const (
vllmAppKeyForUpgrade = "vllm"
vllmImageEnvKey = "IMAGE"
vllmImageTypeNvidia = "nvidia"
vllmImageTypeIntel = "intel"
vllmImageTypeAscend = "ascend"
vllmAppKeyForUpgrade = "vllm"
vllmGB10VersionPrefix = "nvidia-gb10-dspark-"
vllmImageEnvKey = "IMAGE"
vllmImageTypeNvidia = "nvidia"
vllmImageTypeIntel = "intel"
vllmImageTypeAscend = "ascend"
)
func isVllmProOnlyVersion(appKey, version string) bool {
return strings.EqualFold(strings.TrimSpace(appKey), vllmAppKeyForUpgrade) &&
strings.HasPrefix(strings.ToLower(strings.TrimSpace(version)), vllmGB10VersionPrefix)
}
func canAccessVllmVersion(appKey, version string) bool {
return !isVllmProOnlyVersion(appKey, version) || xpack.MultiNodeProvider.IsXpack()
}
func checkVllmVersionAccess(appKey, version string) error {
if !canAccessVllmVersion(appKey, version) {
return buserr.New("ErrVllmGB10ProOnly")
}
return nil
}
func resolveVllmVersionFamily(version, image string) string {
normalizedVersion := strings.ToLower(strings.TrimSpace(version))
if strings.HasPrefix(normalizedVersion, vllmImageTypeIntel+"-") {
+38 -31
View File
@@ -265,6 +265,19 @@ func (w WebsiteService) GetWebsites() ([]response.WebsiteDTO, error) {
return websiteDTOs, nil
}
func newWebsiteCreateHTTPSOp(sslID uint) request.WebsiteHTTPSOp {
return request.WebsiteHTTPSOp{
Enable: true,
WebsiteSSLID: sslID,
Type: constant.SSLExisted,
HttpConfig: constant.HTTPToHTTPS,
SSLProtocol: []string{"TLSv1.3", "TLSv1.2"},
Algorithm: "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED",
Hsts: true,
HstsIncludeSubDomains: true,
}
}
func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error) {
alias := create.Alias
if alias == "default" {
@@ -313,6 +326,7 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
appInstall *model.AppInstall
runtime *model.Runtime
primaryDomain string
initialSSL *websiteInitialSSL
)
if website.Type == constant.Stream {
if create.StreamConfig.StreamPorts == "" {
@@ -339,6 +353,29 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
}
website.PrimaryDomain = primaryDomain
website.Protocol = constant.ProtocolHTTP
for _, domain := range domains {
if domain.SSL {
create.EnableSSL = true
break
}
}
if create.EnableSSL {
if create.WebsiteSSLID == 0 {
return buserr.New("ErrSSLValid")
}
websiteSSL, sslErr := websiteSSLRepo.GetFirst(repo.WithByID(create.WebsiteSSLID))
if sslErr != nil {
return sslErr
}
if websiteSSL.Pem == "" || websiteSSL.PrivateKey == "" {
return buserr.New("ErrSSLValid")
}
sslReq := newWebsiteCreateHTTPSOp(websiteSSL.ID)
website.Protocol = constant.ProtocolHTTPS
website.WebsiteSSLID = websiteSSL.ID
website.HttpConfig = sslReq.HttpConfig
initialSSL = &websiteInitialSSL{certificate: *websiteSSL, request: sslReq}
}
}
createTask, err := task.NewTaskWithOps(website.PrimaryDomain, task.TaskCreate, task.TaskScopeWebsite, create.TaskID, 0)
@@ -490,7 +527,7 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
}
configNginx := func(t *task.Task) error {
if err = configDefaultNginx(website, domains, appInstall, runtime, create.StreamConfig); err != nil {
if err = configDefaultNginx(website, domains, appInstall, runtime, create.StreamConfig, initialSSL); err != nil {
return err
}
if create.Type == constant.Static && create.TemplateOutputID > 0 {
@@ -548,36 +585,6 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
createTask.AddSubTask(i18n.GetMsgByKey("ConfigOpenresty"), configNginx, deleteWebsite)
if create.EnableSSL {
enableSSL := func(t *task.Task) error {
websiteModel, err := websiteSSLRepo.GetFirst(repo.WithByID(create.WebsiteSSLID))
if err != nil {
return err
}
website.Protocol = constant.ProtocolHTTPS
website.WebsiteSSLID = create.WebsiteSSLID
appSSLReq := request.WebsiteHTTPSOp{
WebsiteID: website.ID,
Enable: true,
WebsiteSSLID: websiteModel.ID,
Type: "existed",
HttpConfig: "HTTPToHTTPS",
SSLProtocol: []string{"TLSv1.3", "TLSv1.2"},
Algorithm: "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED",
Hsts: true,
HstsIncludeSubDomains: true,
}
if err = applySSL(website, *websiteModel, appSSLReq); err != nil {
return err
}
if err = websiteRepo.Save(context.Background(), website); err != nil {
return err
}
return nil
}
createTask.AddSubTaskWithIgnoreErr(i18n.GetMsgByKey("EnableSSL"), enableSSL)
}
if len(create.FtpUser) != 0 && len(create.FtpPassword) != 0 {
createFtpUser := func(t *task.Task) error {
indexDir := GetSitePath(*website, SiteIndexDir)
+11 -30
View File
@@ -277,39 +277,12 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
return nil, err
}
}
interPrivateKey, interPublicKey, _, err := createPrivateKey(websiteSSL.KeyType)
if err != nil {
return nil, err
}
notAfter := time.Now()
if req.Unit == "year" {
notAfter = notAfter.AddDate(req.Time, 0, 0)
} else {
notAfter = notAfter.AddDate(0, 0, req.Time)
}
interCsr := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().Unix() + 2),
Subject: rootCsr.Subject,
NotBefore: time.Now(),
NotAfter: notAfter,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 0,
MaxPathLenZero: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
}
interDer, err := x509.CreateCertificate(rand.Reader, interCsr, rootCsr, interPublicKey, rootPrivateKey)
if err != nil {
return nil, err
}
interCert, err := x509.ParseCertificate(interDer)
if err != nil {
return nil, err
}
interCertBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: interCert.Raw,
}
_, publicKey, privateKeyBytes, err := createPrivateKey(websiteSSL.KeyType)
if err != nil {
return nil, err
@@ -330,13 +303,13 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
NotAfter: notAfter,
BasicConstraintsValid: true,
IsCA: false,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
KeyUsage: leafKeyUsage(websiteSSL.KeyType),
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: domains,
IPAddresses: ips,
}
der, err := x509.CreateCertificate(rand.Reader, csr, interCert, publicKey, interPrivateKey)
der, err := x509.CreateCertificate(rand.Reader, csr, rootCsr, publicKey, rootPrivateKey)
if err != nil {
return nil, err
}
@@ -349,7 +322,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
websiteSSL.Pem = string(pem.EncodeToMemory(certBlock)) + string(pem.EncodeToMemory(rootCertBlock)) + string(pem.EncodeToMemory(interCertBlock))
websiteSSL.Pem = string(pem.EncodeToMemory(certBlock))
websiteSSL.PrivateKey = string(privateKeyBytes)
websiteSSL.ExpireDate = cert.NotAfter
websiteSSL.StartDate = cert.NotBefore
@@ -393,6 +366,14 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
return websiteSSL, nil
}
func leafKeyUsage(keyType string) x509.KeyUsage {
usage := x509.KeyUsageDigitalSignature
if ssl.KeyType(keyType) != certcrypto.EC256 && ssl.KeyType(keyType) != certcrypto.EC384 {
usage |= x509.KeyUsageKeyEncipherment
}
return usage
}
func createPrivateKey(keyType string) (privateKey any, publicKey any, privateKeyBytes []byte, err error) {
privateKey, err = certcrypto.GeneratePrivateKey(ssl.KeyType(keyType))
if err != nil {
+1 -1
View File
@@ -32,7 +32,7 @@ func (w WebsiteService) CreateWebsiteDomain(create request.WebsiteDomainCreate)
return nil, err
}
go func() {
_ = OperateFirewallPort(nil, addPorts)
_ = ensureFirewallPorts(addPorts)
}()
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
+139 -124
View File
@@ -11,6 +11,7 @@ import (
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
@@ -255,7 +256,12 @@ func createWebsiteFolder(website *model.Website, runtime *model.Runtime) error {
return nil
}
func configDefaultNginx(website *model.Website, domains []model.WebsiteDomain, appInstall *model.AppInstall, runtime *model.Runtime, streamConfig request.StreamConfig) error {
type websiteInitialSSL struct {
certificate model.WebsiteSSL
request request.WebsiteHTTPSOp
}
func configDefaultNginx(website *model.Website, domains []model.WebsiteDomain, appInstall *model.AppInstall, runtime *model.Runtime, streamConfig request.StreamConfig, initialSSL *websiteInitialSSL) error {
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
@@ -325,6 +331,13 @@ func configDefaultNginx(website *model.Website, domains []model.WebsiteDomain, a
setListen(server, strconv.Itoa(domain.Port), website.IPV6, false, website.DefaultServer, false)
}
server.UpdateServerName(serverNames)
if initialSSL != nil {
plan := buildWebsiteTLSPlan(domains, nginxInstall.HttpPort, nginxInstall.HttpsPort)
applyWebsiteSSLConfig(server, *website, plan, initialSSL.request)
if err = createPemFile(*website, initialSSL.certificate); err != nil {
return err
}
}
siteFolder := path.Join("/www", "sites", website.Alias)
server.UpdateDirective("access_log", []string{path.Join(siteFolder, "log", "access.log"), "main"})
@@ -789,52 +802,22 @@ func createPemFile(website model.Website, websiteSSL model.WebsiteSSL) error {
return nil
}
func getHttpsPort(websiteID uint) map[int]struct{} {
domains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteId(websiteID))
if err != nil {
return nil
}
httpsPorts := make(map[int]struct{})
nginxInstall, _ := getAppInstallByKey(constant.AppOpenresty)
hasDefaultPort := false
for _, domain := range domains {
if domain.Port == nginxInstall.HttpPort {
hasDefaultPort = true
}
if domain.SSL {
httpsPorts[domain.Port] = struct{}{}
}
}
if hasDefaultPort {
httpsPorts[nginxInstall.HttpsPort] = struct{}{}
}
if len(httpsPorts) == 0 {
for _, domain := range domains {
if !domain.SSL {
httpsPorts[domain.Port] = struct{}{}
}
}
}
return httpsPorts
type websiteTLSPlan struct {
httpPorts []int
httpsPorts []int
redirectPort int
defaultHTTPPort int
hasDefaultHTTP bool
}
func applySSL(website *model.Website, websiteSSL model.WebsiteSSL, req request.WebsiteHTTPSOp) error {
nginxFull, err := getNginxFull(website)
if err != nil {
return nil
}
domains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteId(website.ID))
if err != nil {
return nil
}
func buildWebsiteTLSPlan(domains []model.WebsiteDomain, defaultHTTPPort, defaultHTTPSPort int) websiteTLSPlan {
httpPorts := make(map[int]struct{})
httpsPorts := make(map[int]struct{})
sslPort := 0
plan := websiteTLSPlan{defaultHTTPPort: defaultHTTPPort}
hasDefaultPort := false
for _, domain := range domains {
if domain.Port == nginxFull.Install.HttpPort {
hasDefaultPort = true
if domain.Port == defaultHTTPPort {
plan.hasDefaultHTTP = true
}
if domain.SSL {
httpsPorts[domain.Port] = struct{}{}
@@ -842,112 +825,75 @@ func applySSL(website *model.Website, websiteSSL model.WebsiteSSL, req request.W
httpPorts[domain.Port] = struct{}{}
}
}
if hasDefaultPort {
httpsPorts[nginxFull.Install.HttpsPort] = struct{}{}
if plan.hasDefaultHTTP {
httpsPorts[defaultHTTPSPort] = struct{}{}
}
if len(httpsPorts) == 0 {
for port := range httpPorts {
httpsPorts[port] = struct{}{}
}
}
config := nginxFull.SiteConfig.Config
server := config.FindServers()[0]
defaultHttpPort := strconv.Itoa(nginxFull.Install.HttpPort)
defaultHttpPortIPV6 := "[::]:" + defaultHttpPort
for port := range httpsPorts {
sslPort = port
portStr := strconv.Itoa(port)
server.RemoveListenByBind(portStr)
server.RemoveListenByBind("[::]:" + portStr)
setListen(server, portStr, website.IPV6, req.Http3, website.DefaultServer, true)
delete(httpPorts, port)
}
for port := range httpPorts {
plan.httpPorts = append(plan.httpPorts, port)
}
for port := range httpsPorts {
plan.httpsPorts = append(plan.httpsPorts, port)
}
sort.Ints(plan.httpPorts)
sort.Ints(plan.httpsPorts)
if plan.hasDefaultHTTP {
plan.redirectPort = defaultHTTPSPort
} else if len(plan.httpsPorts) > 0 {
plan.redirectPort = plan.httpsPorts[0]
}
return plan
}
server.UpdateDirective("http2", []string{"on"})
func getHttpsPort(websiteID uint) map[int]struct{} {
domains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteId(websiteID))
if err != nil {
return nil
}
nginxInstall, _ := getAppInstallByKey(constant.AppOpenresty)
plan := buildWebsiteTLSPlan(domains, nginxInstall.HttpPort, nginxInstall.HttpsPort)
httpsPorts := make(map[int]struct{}, len(plan.httpsPorts))
for _, port := range plan.httpsPorts {
httpsPorts[port] = struct{}{}
}
return httpsPorts
}
switch req.HttpConfig {
case constant.HTTPSOnly:
server.RemoveListenByBind(defaultHttpPort)
server.RemoveListenByBind(defaultHttpPortIPV6)
server.RemoveDirective("if", []string{"($scheme"})
case constant.HTTPToHTTPS:
if hasDefaultPort {
server.UpdateListen(defaultHttpPort, website.DefaultServer)
if website.IPV6 {
server.UpdateListen(defaultHttpPortIPV6, website.DefaultServer)
}
}
server.AddHTTP2HTTPS(sslPort)
case constant.HTTPAlso:
if hasDefaultPort {
server.UpdateListen(defaultHttpPort, website.DefaultServer)
if website.IPV6 {
server.UpdateListen(defaultHttpPortIPV6, website.DefaultServer)
}
}
server.RemoveDirective("if", []string{"($scheme"})
}
if !req.Hsts {
server.RemoveDirective("add_header", []string{"Strict-Transport-Security", "\"max-age=31536000\""})
server.RemoveDirective("add_header", []string{"Strict-Transport-Security", "\"max-age=31536000; includeSubDomains\""})
}
if !req.Http3 {
for port := range httpsPorts {
server.RemoveListen(strconv.Itoa(port), "quic")
if website.IPV6 {
httpsPortIPV6 := "[::]:" + strconv.Itoa(port)
server.RemoveListen(httpsPortIPV6, "quic")
}
}
server.RemoveDirective("add_header", []string{"Alt-Svc"})
}
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
return err
}
if err = createPemFile(*website, websiteSSL); err != nil {
return err
}
func buildWebsiteSSLParams(alias string, req request.WebsiteHTTPSOp, redirectPort int) []dto.NginxParam {
nginxParams := getNginxParamsFromStaticFile(dto.SSL, []dto.NginxParam{})
for i, param := range nginxParams {
if param.Name == "ssl_certificate" {
nginxParams[i].Params = []string{path.Join("/www", "sites", website.Alias, "ssl", "fullchain.pem")}
}
if param.Name == "ssl_certificate_key" {
nginxParams[i].Params = []string{path.Join("/www", "sites", website.Alias, "ssl", "privkey.pem")}
}
if param.Name == "ssl_protocols" {
for i := range nginxParams {
switch nginxParams[i].Name {
case "ssl_certificate":
nginxParams[i].Params = []string{path.Join("/www", "sites", alias, "ssl", "fullchain.pem")}
case "ssl_certificate_key":
nginxParams[i].Params = []string{path.Join("/www", "sites", alias, "ssl", "privkey.pem")}
case "ssl_protocols":
nginxParams[i].Params = req.SSLProtocol
if len(req.SSLProtocol) == 0 {
nginxParams[i].Params = []string{"TLSv1.3", "TLSv1.2"}
}
}
if param.Name == "ssl_ciphers" {
case "ssl_ciphers":
nginxParams[i].Params = []string{req.Algorithm}
if len(req.Algorithm) == 0 {
nginxParams[i].Params = []string{"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED"}
}
}
if param.Name == "error_page" {
if len(param.Params) < 2 {
continue
}
code := param.Params[0]
if code == "497" {
if sslPort != 443 && param.Params[1] == "https://$host$request_uri" {
param.Params[1] = fmt.Sprintf("https://$host:%d$request_uri", sslPort)
}
case "error_page":
if len(nginxParams[i].Params) >= 2 && nginxParams[i].Params[0] == "497" && redirectPort != 443 && nginxParams[i].Params[1] == "https://$host$request_uri" {
nginxParams[i].Params[1] = fmt.Sprintf("https://$host:%d$request_uri", redirectPort)
}
}
}
if req.Hsts {
var hstsValue string
hstsValue := "\"max-age=31536000\""
if req.HstsIncludeSubDomains {
hstsValue = "\"max-age=31536000; includeSubDomains\""
} else {
hstsValue = "\"max-age=31536000\""
}
nginxParams = append(nginxParams, dto.NginxParam{
Name: "add_header",
@@ -960,11 +906,80 @@ func applySSL(website *model.Website, websiteSSL model.WebsiteSSL, req request.W
Params: []string{"Alt-Svc", "'h3=\":443\"; ma=2592000'"},
})
}
return nginxParams
}
if err := updateNginxConfig(constant.NginxScopeServer, nginxParams, website); err != nil {
func applyWebsiteSSLConfig(server *components.Server, website model.Website, plan websiteTLSPlan, req request.WebsiteHTTPSOp) {
for _, port := range plan.httpsPorts {
portStr := strconv.Itoa(port)
server.RemoveListenByBind(portStr)
server.RemoveListenByBind("[::]:" + portStr)
setListen(server, portStr, website.IPV6, req.Http3, website.DefaultServer, true)
}
server.UpdateDirective("http2", []string{"on"})
defaultHTTPPort := strconv.Itoa(plan.defaultHTTPPort)
switch req.HttpConfig {
case constant.HTTPSOnly:
if plan.hasDefaultHTTP {
server.RemoveListenByBind(defaultHTTPPort)
server.RemoveListenByBind("[::]:" + defaultHTTPPort)
}
server.RemoveDirective("if", []string{"($scheme"})
case constant.HTTPToHTTPS:
if plan.hasDefaultHTTP {
setListen(server, defaultHTTPPort, website.IPV6, false, website.DefaultServer, false)
}
if plan.redirectPort > 0 {
server.AddHTTP2HTTPS(plan.redirectPort)
}
case constant.HTTPAlso:
if plan.hasDefaultHTTP {
setListen(server, defaultHTTPPort, website.IPV6, false, website.DefaultServer, false)
}
server.RemoveDirective("if", []string{"($scheme"})
}
if !req.Hsts {
server.RemoveDirective("add_header", []string{"Strict-Transport-Security", "\"max-age=31536000\""})
server.RemoveDirective("add_header", []string{"Strict-Transport-Security", "\"max-age=31536000; includeSubDomains\""})
}
if !req.Http3 {
for _, port := range plan.httpsPorts {
server.RemoveListen(strconv.Itoa(port), "quic")
if website.IPV6 {
server.RemoveListen("[::]:"+strconv.Itoa(port), "quic")
}
}
server.RemoveDirective("add_header", []string{"Alt-Svc"})
}
for _, param := range buildWebsiteSSLParams(website.Alias, req, plan.redirectPort) {
server.UpdateDirective(param.Name, param.Params)
}
}
func applySSL(website *model.Website, websiteSSL model.WebsiteSSL, req request.WebsiteHTTPSOp) error {
nginxFull, err := getNginxFull(website)
if err != nil {
return nil
}
domains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteId(website.ID))
if err != nil {
return nil
}
config := nginxFull.SiteConfig.Config
server := config.FindServers()[0]
plan := buildWebsiteTLSPlan(domains, nginxFull.Install.HttpPort, nginxFull.Install.HttpsPort)
applyWebsiteSSLConfig(server, *website, plan, req)
if err = createPemFile(*website, websiteSSL); err != nil {
return err
}
return nil
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
return err
}
return nginxCheckAndReload(nginxFull.SiteConfig.OldContent, nginxFull.SiteConfig.FilePath, nginxFull.Install.ContainerName)
}
func getParamArray(key string, param interface{}) []string {
+1
View File
@@ -87,6 +87,7 @@ const (
TaskScopeCronjob = "Cronjob"
TaskScopeClam = "Clam"
TaskScopeSystem = "System"
TaskScopeFirewall = "Firewall"
TaskScopeAppStore = "AppStore"
TaskScopeSnapshot = "Snapshot"
TaskScopeContainer = "Container"
+428 -281
View File
@@ -116,6 +116,16 @@
"formatZH": "删除告警配置 [id]",
"formatEN": "delete alert config [id]"
},
"/alert/config/status": {
"bodyKeys": [
"id",
"status"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "更新告警配置状态 [id][status]",
"formatEN": "update alert config status [id][status]"
},
"/alert/config/update": {
"bodyKeys": [
"id",
@@ -1508,6 +1518,20 @@
"formatZH": "更新角色 [name]",
"formatEN": "update role [name]"
},
"/core/enterprise/settings/footer/reset": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "重置企业版底部链接设置",
"formatEN": "reset Enterprise footer link settings"
},
"/core/enterprise/settings/footer/update": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "更新企业版底部链接设置",
"formatEN": "update Enterprise footer link settings"
},
"/core/enterprise/skills-hub/delete": {
"bodyKeys": [
"id"
@@ -1731,262 +1755,6 @@
"formatZH": "更新 [name]",
"formatEN": "update user [name]"
},
"/core/enterprise/vms": {
"bodyKeys": [
"name"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机 [name]",
"formatEN": "create VM [name]"
},
"/core/enterprise/vms/del": {
"bodyKeys": [
"name"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除虚拟机 [name]",
"formatEN": "delete VM [name]"
},
"/core/enterprise/vms/environment/enable": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "启用虚拟机运行环境",
"formatEN": "enable VM runtime environment"
},
"/core/enterprise/vms/iso": {
"bodyKeys": [
"name",
"type"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机镜像 [name] [type]",
"formatEN": "create VM ISO [name] [type]"
},
"/core/enterprise/vms/iso/del": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_isos",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "删除虚拟机镜像 [name]",
"formatEN": "delete VM ISO [name]"
},
"/core/enterprise/vms/iso/update": {
"bodyKeys": [
"name",
"type"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "更新虚拟机镜像 [name] [type]",
"formatEN": "update VM ISO [name] [type]"
},
"/core/enterprise/vms/networks": {
"bodyKeys": [
"name",
"type"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机网络 [name] [type]",
"formatEN": "create VM network [name] [type]"
},
"/core/enterprise/vms/networks/del": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_networks",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "删除虚拟机网络 [name]",
"formatEN": "delete VM network [name]"
},
"/core/enterprise/vms/networks/update": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_networks",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "更新虚拟机网络 [name]",
"formatEN": "update VM network [name]"
},
"/core/enterprise/vms/operate": {
"bodyKeys": [
"name",
"operate"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "操作虚拟机 [name] [operate]",
"formatEN": "operate VM [name] [operate]"
},
"/core/enterprise/vms/orphans/clean": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "清理虚拟机孤立数据",
"formatEN": "clean VM orphaned data"
},
"/core/enterprise/vms/rename": {
"bodyKeys": [
"name",
"newName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "重命名虚拟机 [name] 为 [newName]",
"formatEN": "rename VM [name] to [newName]"
},
"/core/enterprise/vms/snapshots": {
"bodyKeys": [
"name",
"snapshotName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机 [name] 快照 [snapshotName]",
"formatEN": "create VM [name] snapshot [snapshotName]"
},
"/core/enterprise/vms/snapshots/del": {
"bodyKeys": [
"name",
"snapshotName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除虚拟机 [name] 快照 [snapshotName]",
"formatEN": "delete VM [name] snapshot [snapshotName]"
},
"/core/enterprise/vms/snapshots/recover": {
"bodyKeys": [
"name",
"snapshotName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "恢复虚拟机 [name] 快照 [snapshotName]",
"formatEN": "recover VM [name] snapshot [snapshotName]"
},
"/core/enterprise/vms/storages": {
"bodyKeys": [
"name",
"type"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机存储 [name] [type]",
"formatEN": "create VM storage [name] [type]"
},
"/core/enterprise/vms/storages/del": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_storages",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "删除虚拟机存储 [name]",
"formatEN": "delete VM storage [name]"
},
"/core/enterprise/vms/storages/update": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_storages",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "更新虚拟机存储 [name]",
"formatEN": "update VM storage [name]"
},
"/core/enterprise/vms/sync": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "同步虚拟机",
"formatEN": "sync virtual machines"
},
"/core/enterprise/vms/templates": {
"bodyKeys": [
"name",
"templateName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机 [name] 模板 [templateName]",
"formatEN": "create VM [name] template [templateName]"
},
"/core/enterprise/vms/templates/del": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_templates",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "删除虚拟机模板 [name]",
"formatEN": "delete VM template [name]"
},
"/core/enterprise/vms/update": {
"bodyKeys": [
"name"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "更新虚拟机 [name]",
"formatEN": "update VM [name]"
},
"/core/groups": {
"bodyKeys": [
"name",
@@ -2691,6 +2459,271 @@
"formatZH": "同步 SSL 证书 [primaryDomain]",
"formatEN": "sync SSL certificate [primaryDomain]"
},
"/core/xpack/vms": {
"bodyKeys": [
"name"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机 [name]",
"formatEN": "create VM [name]"
},
"/core/xpack/vms/del": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "virtual_machines",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "删除虚拟机 [name]",
"formatEN": "delete VM [name]"
},
"/core/xpack/vms/environment/enable": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "启用虚拟机运行环境",
"formatEN": "enable VM runtime environment"
},
"/core/xpack/vms/iso": {
"bodyKeys": [
"name",
"type"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机镜像 [name] [type]",
"formatEN": "create VM ISO [name] [type]"
},
"/core/xpack/vms/iso/del": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_isos",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "删除虚拟机镜像 [name]",
"formatEN": "delete VM ISO [name]"
},
"/core/xpack/vms/iso/update": {
"bodyKeys": [
"name",
"type"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "更新虚拟机镜像 [name] [type]",
"formatEN": "update VM ISO [name] [type]"
},
"/core/xpack/vms/networks": {
"bodyKeys": [
"name",
"type"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机网络 [name] [type]",
"formatEN": "create VM network [name] [type]"
},
"/core/xpack/vms/networks/del": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_networks",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "删除虚拟机网络 [name]",
"formatEN": "delete VM network [name]"
},
"/core/xpack/vms/networks/update": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_networks",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "更新虚拟机网络 [name]",
"formatEN": "update VM network [name]"
},
"/core/xpack/vms/operate": {
"bodyKeys": [
"name",
"operate"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "操作虚拟机 [name] [operate]",
"formatEN": "operate VM [name] [operate]"
},
"/core/xpack/vms/orphans/clean": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "清理虚拟机孤立数据",
"formatEN": "clean VM orphaned data"
},
"/core/xpack/vms/rename": {
"bodyKeys": [
"name",
"newName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "重命名虚拟机 [name] 为 [newName]",
"formatEN": "rename VM [name] to [newName]"
},
"/core/xpack/vms/snapshots": {
"bodyKeys": [
"name",
"snapshotName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机 [name] 快照 [snapshotName]",
"formatEN": "create VM [name] snapshot [snapshotName]"
},
"/core/xpack/vms/snapshots/del": {
"bodyKeys": [
"name",
"snapshotName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除虚拟机 [name] 快照 [snapshotName]",
"formatEN": "delete VM [name] snapshot [snapshotName]"
},
"/core/xpack/vms/snapshots/recover": {
"bodyKeys": [
"name",
"snapshotName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "恢复虚拟机 [name] 快照 [snapshotName]",
"formatEN": "recover VM [name] snapshot [snapshotName]"
},
"/core/xpack/vms/storages": {
"bodyKeys": [
"name",
"type"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机存储 [name] [type]",
"formatEN": "create VM storage [name] [type]"
},
"/core/xpack/vms/storages/del": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_storages",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "删除虚拟机存储 [name]",
"formatEN": "delete VM storage [name]"
},
"/core/xpack/vms/storages/update": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_storages",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "更新虚拟机存储 [name]",
"formatEN": "update VM storage [name]"
},
"/core/xpack/vms/sync": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "同步虚拟机",
"formatEN": "sync virtual machines"
},
"/core/xpack/vms/templates": {
"bodyKeys": [
"name",
"templateName"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建虚拟机 [name] 模板 [templateName]",
"formatEN": "create VM [name] template [templateName]"
},
"/core/xpack/vms/templates/del": {
"bodyKeys": [
"id"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "id",
"isList": false,
"db": "vm_templates",
"output_column": "name",
"output_value": "name"
}
],
"formatZH": "删除虚拟机模板 [name]",
"formatEN": "delete VM template [name]"
},
"/core/xpack/vms/update": {
"bodyKeys": [
"name"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "更新虚拟机 [name]",
"formatEN": "update VM [name]"
},
"/cronjobs": {
"bodyKeys": [
"type",
@@ -3489,6 +3522,15 @@
"formatZH": "下载 url =\u003e [path]/[name]",
"formatEN": "Download url =\u003e [path]/[name]"
},
"/files/wget/process/remove": {
"bodyKeys": [
"keys"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "移除已结束下载记录 [keys]",
"formatEN": "Remove finished download records [keys]"
},
"/files/wget/stop": {
"bodyKeys": [
"key"
@@ -3603,42 +3645,61 @@
"formatZH": "卸载磁盘 [device] 从 [mountPoint]",
"formatEN": "Unmount disk [device] from [mountPoint]"
},
"/hosts/firewall/docker/operate": {
"bodyKeys": [
"operation"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "[operation] Docker 端口防护",
"formatEN": "[operation] Docker port guard"
},
"/hosts/firewall/docker/policies/batch": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "批量更新 Docker 端口防护策略",
"formatEN": "batch update Docker port guard policies"
},
"/hosts/firewall/docker/policies/delete/batch": {
"bodyKeys": [
"uuids"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除 Docker 端口防护策略 [uuids]",
"formatEN": "delete Docker port guard policies [uuids]"
},
"/hosts/firewall/docker/sync": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "同步 Docker 端口防护规则",
"formatEN": "sync Docker port guard rules"
},
"/hosts/firewall/filter/operate": {
"bodyKeys": [
"operate"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "[operate] iptables filter 防火墙",
"formatEN": "[operate] iptables filter firewall"
"formatZH": "[operate] 防火墙过滤链",
"formatEN": "[operate] firewall filter chain"
},
"/hosts/firewall/filter/rule/operate": {
"bodyKeys": [
"operation",
"chain"
],
"/hosts/firewall/forward/enable": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "[operation] filter规则到 [chain]",
"formatEN": "[operation] filter rule to [chain]"
"formatZH": "初始化并启用端口转发",
"formatEN": "initialize and enable port forwarding"
},
"/hosts/firewall/forward": {
"/hosts/firewall/forward/operate": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "更新端口转发规则",
"formatEN": "update port forward rules"
},
"/hosts/firewall/ip": {
"bodyKeys": [
"strategy",
"address"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "添加 ip 规则 [strategy] [address]",
"formatEN": "create address rules [strategy][address]"
},
"/hosts/firewall/operate": {
"bodyKeys": [
"operation"
@@ -3648,15 +3709,101 @@
"formatZH": "[operation] 防火墙",
"formatEN": "[operation] firewall"
},
"/hosts/firewall/port": {
"/hosts/firewall/rules": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "添加防火墙规则",
"formatEN": "create firewall rules"
},
"/hosts/firewall/rules/adopt": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "纳管防火墙规则",
"formatEN": "adopt firewall rule"
},
"/hosts/firewall/rules/delete": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除防火墙规则",
"formatEN": "delete firewall rules"
},
"/hosts/firewall/rules/reorder": {
"bodyKeys": [
"port",
"strategy"
"uuid"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "添加端口规则 [strategy] [port]",
"formatEN": "create port rules [strategy][port]"
"formatZH": "调整防火墙规则顺序 [uuid]",
"formatEN": "reorder firewall rule [uuid]"
},
"/hosts/firewall/rules/reset": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "重置防火墙规则",
"formatEN": "reset firewall rules"
},
"/hosts/firewall/rules/sync": {
"bodyKeys": [
"subsystem",
"sourceProvider",
"targetProvider"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "同步 [subsystem] 防火墙规则到 [targetProvider]",
"formatEN": "sync [subsystem] firewall rules to [targetProvider]"
},
"/hosts/firewall/rules/update": {
"bodyKeys": [
"uuid"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "更新防火墙规则 [uuid]",
"formatEN": "update firewall rule [uuid]"
},
"/hosts/firewall/settings/operate": {
"bodyKeys": [
"subsystem",
"backend",
"operation"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "防火墙子系统 [subsystem] 后端 [operation] [backend]",
"formatEN": "[operation] firewall [subsystem] backend [backend]"
},
"/hosts/firewall/settings/whitelist": {
"bodyKeys": [
"rule"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "创建防火墙端口白名单",
"formatEN": "create firewall port whitelist"
},
"/hosts/firewall/settings/whitelist/delete": {
"bodyKeys": [
"rules"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除防火墙端口白名单",
"formatEN": "delete firewall port whitelist"
},
"/hosts/firewall/settings/whitelist/update": {
"bodyKeys": [
"oldRule",
"rule"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "编辑防火墙端口白名单",
"formatEN": "update firewall port whitelist"
},
"/hosts/monitor/clean": {
"bodyKeys": [],
+6 -2
View File
@@ -1,4 +1,8 @@
gzip on;
gzip_comp_level 6;
gzip_vary on;
gzip_min_length 1k;
gzip_types text/plain text/css text/xml text/javascript text/x-component application/json application/javascript application/x-javascript application/xml application/xhtml+xml application/rss+xml application/atom+xml application/x-font-ttf application/vnd.ms-fontobject image/svg+xml image/x-icon font/opentype;
gzip_buffers 4 16k;
gzip_http_version 1.1;
gzip_comp_level 5;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/json application/ld+json application/javascript application/x-javascript application/xml application/xhtml+xml application/rss+xml application/atom+xml application/wasm image/svg+xml font/ttf font/otf;
-3
View File
@@ -7,9 +7,6 @@ const (
SystemRestart = "systemRestart"
FirewallPortWhiteList = "FirewallPortWhiteList"
FirewallPortWhiteListValue = "80/tcp,443/tcp,443/udp"
TypeWebsite = "website"
TypePhp = "php"
TypeSSL = "ssl"
+44
View File
@@ -0,0 +1,44 @@
package constant
const (
FirewallProviderFirewalld = "firewalld"
FirewallProviderUFW = "ufw"
FirewallProviderIptables = "iptables"
FirewallProviderNftables = "nftables"
FirewallBackendNotInstalled = "backend_not_installed"
FirewallFamilyIPv4 = "ipv4"
FirewallFamilyIPv6 = "ipv6"
FirewallFamilyInet = "inet"
FirewallBasicBeforeChain = "1PANEL_BASIC_BEFORE"
FirewallBasicChain = "1PANEL_BASIC"
FirewallBasicAfterChain = "1PANEL_BASIC_AFTER"
)
const (
FirewallSystemBackendKey = "FirewallProvider"
FirewallForwardingBackendKey = "ForwardingBackend"
FirewallDockerBackendKey = "DockerFirewallBackend"
FirewallDockerPortGuardStatusKey = "DockerPortGuardStatus"
FirewallFilterInitializedKey = "IptablesStatus"
FirewallForwardingInitializedKey = "IptablesForwardStatus"
FirewallPingStatusKey = "BanPing"
FirewallPortWhiteList = "FirewallPortWhiteList"
FirewallPortWhiteListValue = `[{"port":"80","protocol":"tcp","sources":["0.0.0.0/0","::/0"]},{"port":"443","protocol":"tcp","sources":["0.0.0.0/0","::/0"]},{"port":"443","protocol":"udp","sources":["0.0.0.0/0","::/0"]}]`
)
const (
FirewallSystemAcceptedPortSourcePrefix = "accepted-port:"
FirewallRuleOriginCreated = "created"
FirewallRuleOriginAdopted = "adopted"
FirewallRuleSourceUser = "user"
FirewallRuleSourceImported = "imported"
FirewallRuleSourcePanel = "panel"
FirewallRuleSourceSecurity = "security"
FirewallRuleSourceApp = "application"
)
+19 -18
View File
@@ -1,6 +1,6 @@
module github.com/1Panel-dev/1Panel/agent
go 1.25.10
go 1.26.1
replace github.com/moby/go-archive => github.com/moby/go-archive v0.1.0
@@ -10,7 +10,7 @@ require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/compose-spec/compose-go/v2 v2.14.0
github.com/creack/pty v1.1.24
github.com/docker/cli v29.7.1+incompatible
github.com/docker/cli v29.7.2+incompatible
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.8.1
github.com/fsnotify/fsnotify v1.10.1
@@ -28,11 +28,11 @@ require (
github.com/jackc/pgx/v5 v5.10.0
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.19.1
github.com/klauspost/compress v1.19.2
github.com/mattn/go-shellwords v1.0.14
github.com/mholt/archiver/v4 v4.0.0-alpha.8
github.com/miekg/dns v1.1.72
github.com/minio/minio-go/v7 v7.2.1
github.com/miekg/dns v1.1.73
github.com/minio/minio-go/v7 v7.3.0
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/opencontainers/image-spec v1.1.1
github.com/oschwald/maxminddb-golang v1.13.1
@@ -42,7 +42,7 @@ require (
github.com/qiniu/go-sdk/v7 v7.27.0
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.26.7
github.com/sirupsen/logrus v1.9.4
github.com/sirupsen/logrus v1.10.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2
@@ -52,12 +52,12 @@ require (
github.com/tklauser/go-sysconf v0.4.0
github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19
github.com/upyun/go-sdk v2.1.0+incompatible
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
go.mongodb.org/mongo-driver/v2 v2.8.2
golang.org/x/crypto v0.55.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
golang.org/x/text v0.41.0
golang.org/x/time v0.15.0
google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478
gopkg.in/ini.v1 v1.67.3
@@ -143,7 +143,7 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kr/fs v0.1.0 // indirect
@@ -182,7 +182,7 @@ require (
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
@@ -209,17 +209,18 @@ require (
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/arch v0.26.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/fileutil v1.4.0 // indirect
modernc.org/libc v1.72.0 // indirect
+44 -44
View File
@@ -228,8 +228,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v29.7.1+incompatible h1:ILZpP6B7fedIr6ANy824QkDp1WMJuouIq0O2SrBkB2w=
github.com/docker/cli v29.7.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY=
github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
@@ -523,12 +523,12 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
@@ -576,16 +576,16 @@ github.com/mholt/archiver/v4 v4.0.0-alpha.8/go.mod h1:5f7FUYGXdJWUjESffJaYR4R60V
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw=
github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
github.com/minio/minio-go/v7 v7.3.0 h1:HM4pFCSQq/TK+j0/zmorSh5ddh81iDgRgU0BG0Vz/YU=
github.com/minio/minio-go/v7 v7.3.0/go.mod h1:KUPWdecEO1LWyUz+sTGXAuf2jZHrPh5fCsRH86QbPfk=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
@@ -698,8 +698,6 @@ github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6J
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -728,8 +726,8 @@ github.com/qiniu/go-sdk/v7 v7.27.0 h1:n+2U0S5fhbmG/lN/agO8KcYJaQDqROUVShtnp56Mkw
github.com/qiniu/go-sdk/v7 v7.27.0/go.mod h1:pTwVR1B+8SXcPLhDzBUasiKFTD9F7jRglRDR553BW3k=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -759,8 +757,8 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
@@ -802,8 +800,9 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
@@ -878,8 +877,8 @@ go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lL
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w=
go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -890,20 +889,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
@@ -916,8 +915,9 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
@@ -951,8 +951,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -994,8 +994,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1050,8 +1050,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1186,8 +1186,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1249,8 +1249,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1315,8 +1315,8 @@ google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxH
google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 h1:aLsVTW0lZ8+IY5u/ERjZSCvAmhuR7slKzyha3YikDNA=
google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478/go.mod h1:YJAzKjfHIUHb9T+bfu8L7mthAp7VVXQBUs1PLdBWS7M=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -1336,8 +1336,8 @@ google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+50
View File
@@ -139,6 +139,7 @@ ErrComposeProjectNameParse: 'Failed to parse the project name from Docker Compos
ErrComposeProjectNameEmpty: 'Unable to derive a project name from Docker Compose config or the file parent directory. Enter a name and try again.'
ErrComposeNameInvalid: 'Invalid Compose project name: it must start with a lowercase letter or number, contain only lowercase letters, numbers, hyphens, and underscores, and be 1-256 characters long.'
ErrAppVersionUnavailable: 'This application version has been removed from the remote service. Please select another version and try again.'
ErrVllmGB10ProOnly: 'This vLLM version is available only in 1Panel Professional Edition.'
ErrAppWarn: 'App status abnormal; check logs'
ErrAppParamKey: 'Invalid app parameter: {{ .name }}'
ErrAppUpgrade: 'App upgrade failed: {{ .name }} {{ .err }}'
@@ -215,6 +216,9 @@ ErrDomainFormat: 'Invalid domain format: {{ .name }}'
ErrDefaultAlias: 'default is reserved; use another alias'
ErrParentWebsite: 'Delete subsite {{ .name }} first'
ErrBuildDirNotFound: 'The build directory does not exist'
ErrBrotliDisabled: 'The brotli module is not enabled, enable and build it first'
ErrBrotliUnsupported: 'The panel could not wire brotli settings into nginx.conf automatically; check the file or upgrade OpenResty'
ErrModuleBuildUnsupported: 'This OpenResty version cannot build modules, upgrade it first'
ErrImageNotExist: 'Runtime image not found: {{ .name }}'
ErrProxyIsUsed: 'Load balancer is used by reverse proxy'
ErrSSLValid: 'Certificate file is invalid'
@@ -591,6 +595,10 @@ ContainerDisconnectOld: 'Release original container {{ .name }} network addresse
ContainerCreateReplacement: 'Create replacement container {{ .name }}'
ContainerStartReplacement: 'Start replacement container {{ .name }}'
ContainerWaitReplacement: 'Check replacement container {{ .name }} readiness'
ContainerStartupDiagnostic: 'Startup failure diagnostics for replacement container {{ .name }}'
ContainerRecentLogs: 'Recent container logs (last 200 lines)'
ContainerDiagnosticLogsFailed: 'Failed to load logs for failed container {{ .name }}: {{ .err }}'
ContainerDiagnosticLogsEmpty: 'No container logs are available'
ContainerRollbackRestartOld: 'Restart original container {{ .name }}'
ContainerRollbackRemoveReplacement: 'Remove failed replacement container {{ .name }}'
ContainerRollbackRenameOld: 'Restore original container {{ .name }} name'
@@ -653,6 +661,7 @@ XfsNotFound: 'xfs not found; install xfsprogs first'
# terminal
TerminalAIBlockedRiskyCommand: 'blocked risky command: {{ .command }}'
TerminalAIThinking: 'AI is thinking...'
TerminalOutputTruncated: '[output truncated, showing recent output only]'
TerminalAIReadyToExecute: 'Thinking complete, press Enter to execute (duration: {{ .duration }}, tokens: {{ .tokens }})'
TerminalAIRequestFailed: 'AI request failed: {{ .err }}'
@@ -669,3 +678,44 @@ AIProviderBailian: 'Alibaba Cloud Model Studio'
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
AIProviderArk: 'Volcengine Ark'
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
# Firewall
ErrDockerIptablesChainUnavailable: 'The Docker DOCKER-USER chain was not found. Restart Docker and try again'
ErrDockerNftablesChainUnavailable: 'The Docker nftables IPv4 firewall chain was not found. Verify that the current Docker version supports the nftables firewall backend, restart Docker, and try again'
ErrDockerForwardPolicyDrop: '{{ .family }} FORWARD defaults to DROP. Adjust the policy and retry'
ErrUFWRuleAdopt: 'Failed to adopt the UFW rule: {{ .detail }}. If the installed UFW version is earlier than 0.35, upgrade UFW and try again'
Firewall: 'Firewall'
FirewallTaskHost: 'Host firewall'
FirewallTaskForwarding: 'Port forwarding'
FirewallTaskDocker: 'Container port protection'
FirewallRuleTaskCreate: 'Create firewall rules [{{ .name }}]'
FirewallRuleTaskUpdate: 'Update firewall rules [{{ .name }}]'
FirewallRuleTaskDelete: 'Delete firewall rules [{{ .name }}]'
FirewallRuleTaskSync: 'Synchronize firewall rules [{{ .name }}]'
FirewallTaskInitialize: 'Initialize firewall [{{ .name }}]'
FirewallCreateRulesStep: 'Create firewall rules'
FirewallCreateRulesResult: 'Creation result: {{ .succeeded }} succeeded, {{ .failed }} failed, {{ .skipped }} not executed'
FirewallRuleOperationResult: 'Operation result: {{ .succeeded }} succeeded, {{ .failed }} failed'
FirewallCreateRuleSkipped: 'Not executed'
FirewallCreateBatchStep: 'Submit {{ .count }} rules as a batch to {{ .backend }}'
FirewallImportRuleConversion: '[{{ .index }}/{{ .total }}] {{ .source }} → {{ .target }}: {{ .rule }}; converted to {{ .count }} rules'
FirewallCreateRuleExecutionFailed: 'Rule creation failed; no database record was saved and executed commands were not rolled back'
FirewallCreateRulePersistenceFailed: 'The rule was created, but its management record could not be saved'
FirewallAdoptRulePersistenceFailed: 'The rule was adopted, but its management record could not be saved'
FirewallSyncOperationsResult: 'Synchronization operations: {{ .removed }} deleted, {{ .created }} created, {{ .failed }} failed, {{ .skipped }} not executed, {{ .unchanged }} already matching (no changes needed)'
FirewallSyncRuleUnchanged: 'Already matching; no changes needed'
FirewallSyncStep: 'Synchronize rules to {{ .name }}'
FirewallSyncFailed: '{{ .failed }} firewall rules failed to synchronize'
FirewallResetSourceStep: 'Reset and disable source firewall {{ .name }}'
FirewallResetSourceResult: 'Source firewall reset completed; {{ .removed }} database rules were deleted'
FirewallInitializeChainsStep: 'Initialize and bind {{ .name }} base chains'
FirewallRestoreRulesStep: 'Restore database rules to {{ .name }}'
FirewallSyncWhitelistStep: 'Synchronize firewall port whitelist'
FirewallEnableForwardingStep: 'Enable and bind port forwarding chains'
FirewallRestoreForwardingRulesStep: 'Restore database port forwarding rules'
FirewallInspectDockerGuardStep: 'Inspect Docker firewall backend and policies'
FirewallInitializeDockerGuardStep: 'Initialize and bind {{ .name }} port guard chains'
FirewallPersistDockerGuardStep: 'Persist Docker port guard status'
ErrFirewallRuleScopeChange: "The current firewall does not support changing a rule's scope (such as its IPv4/IPv6 address family). Please create a new rule."
FirewallWhitelistReleased: "{{ .name }}: whitelist protection released; allow rule retained. To close the port, delete the rule manually from the rule list"
FirewallWhitelistRequired: "{{ .name }}: protected by mandatory system port rules"
+50
View File
@@ -139,6 +139,7 @@ ErrComposeProjectNameParse: 'No se pudo analizar el nombre del proyecto desde la
ErrComposeProjectNameEmpty: 'No se pudo derivar un nombre de proyecto desde la configuración de Docker Compose ni desde el directorio padre del archivo. Introduzca un nombre e inténtelo de nuevo.'
ErrComposeNameInvalid: 'Nombre de proyecto Compose no válido: debe comenzar con una letra minúscula o un número, contener solo letras minúsculas, números, guiones y guiones bajos, y tener entre 1 y 256 caracteres.'
ErrAppVersionUnavailable: 'Esta versión de la aplicación se ha eliminado del servicio remoto. Seleccione otra versión e inténtelo de nuevo.'
ErrVllmGB10ProOnly: 'Esta versión de vLLM solo está disponible en 1Panel Professional Edition.'
ErrAppWarn: 'Estado anómalo, revise el log'
ErrAppParamKey: 'El campo de parámetro {{ .name }} es anómalo'
ErrAppUpgrade: 'La actualización de la aplicación {{ .name }} falló {{ .err }}'
@@ -215,6 +216,9 @@ ErrDomainFormat: 'El formato del dominio {{ .name }} es incorrecto'
ErrDefaultAlias: 'default es un código reservado, use otro'
ErrParentWebsite: 'Primero debe eliminar el sub-sitio {{ .name }}'
ErrBuildDirNotFound: 'El directorio de compilación no existe'
ErrBrotliDisabled: 'El módulo brotli no está habilitado, actívelo y compílelo primero'
ErrBrotliUnsupported: 'El panel no pudo conectar automáticamente la configuración brotli a nginx.conf; revise el archivo o actualice OpenResty'
ErrModuleBuildUnsupported: 'Esta versión de OpenResty no puede compilar módulos, actualícela primero'
ErrImageNotExist: 'La imagen del entorno {{ .name }} no existe, edítela de nuevo'
ErrProxyIsUsed: 'El balanceo de carga ya está usado por un proxy reverso, no se puede eliminar'
ErrSSLValid: 'Archivo de certificado anómalo, revise el estado del certificado'
@@ -591,6 +595,10 @@ ContainerDisconnectOld: 'Liberar las direcciones de red del contenedor original
ContainerCreateReplacement: 'Crear el contenedor de reemplazo {{ .name }}'
ContainerStartReplacement: 'Iniciar el contenedor de reemplazo {{ .name }}'
ContainerWaitReplacement: 'Verificar que el contenedor de reemplazo {{ .name }} esté listo'
ContainerStartupDiagnostic: 'Diagnóstico del fallo de inicio del contenedor de reemplazo {{ .name }}'
ContainerRecentLogs: 'Registros recientes del contenedor (últimas 200 líneas)'
ContainerDiagnosticLogsFailed: 'No se pudieron obtener los registros del contenedor fallido {{ .name }}: {{ .err }}'
ContainerDiagnosticLogsEmpty: 'No hay registros del contenedor disponibles'
ContainerRollbackRestartOld: 'Reiniciar el contenedor original {{ .name }}'
ContainerRollbackRemoveReplacement: 'Eliminar el contenedor de reemplazo fallido {{ .name }}'
ContainerRollbackRenameOld: 'Restaurar el nombre del contenedor original {{ .name }}'
@@ -653,6 +661,7 @@ XfsNotFound: 'No se detectó el sistema de archivos xfs instale xfsprogs primero
# terminal
TerminalAIBlockedRiskyCommand: 'Comando de riesgo bloqueado: {{ .command }}'
TerminalAIThinking: 'La IA está pensando...'
TerminalOutputTruncated: '[salida truncada, se muestra solo la salida reciente]'
TerminalAIReadyToExecute: 'Pensamiento completado, pulsa Enter para ejecutar (duración: {{ .duration }}, token: {{ .tokens }})'
TerminalAIRequestFailed: 'La solicitud de AI ha fallado: {{ .err }}'
@@ -669,3 +678,44 @@ AIProviderBailian: 'Alibaba Cloud Model Studio'
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
AIProviderArk: 'Volcengine Ark'
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
# Firewall
ErrDockerIptablesChainUnavailable: 'No se encontró la cadena DOCKER-USER de Docker. Reinicie Docker y vuelva a intentarlo'
ErrDockerNftablesChainUnavailable: 'No se encontró la cadena de firewall IPv4 de nftables de Docker. Compruebe que la versión actual de Docker admita el backend de firewall nftables, reinicie Docker y vuelva a intentarlo'
ErrDockerForwardPolicyDrop: 'La política predeterminada de FORWARD para {{ .family }} es DROP. Ajústela y vuelva a intentarlo'
ErrUFWRuleAdopt: 'No se pudo administrar la regla UFW: {{ .detail }}. Si la versión actual de UFW es anterior a 0.35, actualice UFW y vuelva a intentarlo'
Firewall: 'Firewall'
FirewallTaskHost: 'Cortafuegos del host'
FirewallTaskForwarding: 'Reenvío de puertos'
FirewallTaskDocker: 'Protección de puertos de contenedores'
FirewallRuleTaskCreate: 'Crear reglas de cortafuegos [{{ .name }}]'
FirewallRuleTaskUpdate: 'Actualizar reglas de cortafuegos [{{ .name }}]'
FirewallRuleTaskDelete: 'Eliminar reglas de cortafuegos [{{ .name }}]'
FirewallRuleTaskSync: 'Sincronizar reglas de cortafuegos [{{ .name }}]'
FirewallTaskInitialize: 'Inicializar cortafuegos [{{ .name }}]'
FirewallCreateRulesStep: 'Crear reglas de cortafuegos'
FirewallCreateRulesResult: 'Resultado de creación: {{ .succeeded }} correctas, {{ .failed }} fallidas, {{ .skipped }} sin ejecutar'
FirewallRuleOperationResult: 'Resultado de la operación: {{ .succeeded }} correctas, {{ .failed }} fallidas'
FirewallCreateRuleSkipped: 'Sin ejecutar'
FirewallCreateBatchStep: 'Enviar {{ .count }} reglas en un lote a {{ .backend }}'
FirewallImportRuleConversion: '[{{ .index }}/{{ .total }}] {{ .source }} → {{ .target }}: {{ .rule }}; convertido en {{ .count }} reglas'
FirewallCreateRuleExecutionFailed: 'Error al crear la regla. No se guardó ningún registro en la base de datos ni se revirtieron los comandos ejecutados'
FirewallCreateRulePersistenceFailed: 'La regla se creó, pero no se pudo guardar su registro de gestión'
FirewallAdoptRulePersistenceFailed: 'Se ejecutó la adopción de la regla, pero no se pudo guardar su registro de gestión'
FirewallSyncOperationsResult: 'Operaciones de sincronización: {{ .removed }} eliminadas, {{ .created }} creadas, {{ .failed }} fallidas, {{ .skipped }} sin ejecutar, {{ .unchanged }} ya coinciden (sin cambios necesarios)'
FirewallSyncRuleUnchanged: 'Ya coincide; no requiere cambios'
FirewallSyncStep: 'Sincronizar reglas con {{ .name }}'
FirewallSyncFailed: 'No se pudieron sincronizar {{ .failed }} reglas del firewall'
FirewallResetSourceStep: 'Restablecer y desactivar el firewall de origen {{ .name }}'
FirewallResetSourceResult: 'Firewall de origen restablecido; se eliminaron {{ .removed }} reglas de la base de datos'
FirewallInitializeChainsStep: 'Inicializar y vincular las cadenas base de {{ .name }}'
FirewallRestoreRulesStep: 'Restaurar las reglas de la base de datos en {{ .name }}'
FirewallSyncWhitelistStep: 'Sincronizar la lista de puertos permitidos del firewall'
FirewallEnableForwardingStep: 'Habilitar y vincular las cadenas de reenvío de puertos'
FirewallRestoreForwardingRulesStep: 'Restaurar las reglas de reenvío de puertos de la base de datos'
FirewallInspectDockerGuardStep: 'Inspeccionar el backend del firewall de Docker y las políticas'
FirewallInitializeDockerGuardStep: 'Inicializar y vincular las cadenas de protección de puertos de {{ .name }}'
FirewallPersistDockerGuardStep: 'Guardar el estado de protección de puertos de Docker'
ErrFirewallRuleScopeChange: "El cortafuegos actual no permite cambiar el ámbito de una regla (como su familia de direcciones IPv4/IPv6). Cree una regla nueva."
FirewallWhitelistReleased: "{{ .name }}: protección de la lista de permitidos retirada; se conserva la regla de permiso. Para cerrar el puerto, elimine la regla manualmente de la lista"
FirewallWhitelistRequired: "{{ .name }}: protegido por las reglas de puertos obligatorios del sistema"
+50
View File
@@ -139,6 +139,7 @@ ErrComposeProjectNameParse: 'تجزیه نام پروژه از خروجی پیک
ErrComposeProjectNameEmpty: 'نام پروژه از پیکربندی Docker Compose یا پوشه والد فایل قابل استخراج نیست. یک نام وارد کرده و دوباره تلاش کنید.'
ErrComposeNameInvalid: 'نام پروژه Compose نامعتبر است: باید با حرف کوچک یا عدد شروع شود، فقط شامل حروف کوچک، اعداد، خط تیره و زیرخط باشد و ۱ تا ۲۵۶ نویسه داشته باشد.'
ErrAppVersionUnavailable: 'این نسخه برنامه از سرویس راه دور حذف شده است. لطفاً نسخه دیگری را انتخاب کرده و دوباره تلاش کنید.'
ErrVllmGB10ProOnly: 'این نسخه vLLM فقط در نسخه حرفه‌ای 1Panel در دسترس است.'
ErrAppWarn: 'وضعیت برنامه غیرعادی است؛ لاگ‌ها را بررسی کنید'
ErrAppParamKey: 'پارامتر برنامه نامعتبر است: {{ .name }}'
ErrAppUpgrade: 'ارتقاء برنامه ناموفق بود: {{ .name }} {{ .err }}'
@@ -215,6 +216,9 @@ ErrDomainFormat: 'فرمت دامنه نامعتبر است: {{ .name }}'
ErrDefaultAlias: 'default رزرو شده است؛ از نام مستعار دیگری استفاده کنید'
ErrParentWebsite: 'ابتدا زیرسایت {{ .name }} را حذف کنید'
ErrBuildDirNotFound: 'دایرکتوری ساخت وجود ندارد'
ErrBrotliDisabled: 'ماژول brotli فعال نیست، ابتدا آن را فعال و بیلد کنید'
ErrBrotliUnsupported: 'پنل نتوانست تنظیمات brotli را به‌صورت خودکار به nginx.conf متصل کند؛ فایل را بررسی کنید یا OpenResty را ارتقا دهید'
ErrModuleBuildUnsupported: 'این نسخه OpenResty نمی‌تواند ماژول بسازد، ابتدا آن را ارتقا دهید'
ErrImageNotExist: 'تصویر محیط اجرا یافت نشد: {{ .name }}'
ErrProxyIsUsed: 'تعادل بار توسط پراکسی معکوس استفاده می‌شود'
ErrSSLValid: 'فایل گواهی نامعتبر است'
@@ -591,6 +595,10 @@ ContainerDisconnectOld: 'آزادسازی آدرس‌های شبکه کانتی
ContainerCreateReplacement: 'ایجاد کانتینر جایگزین {{ .name }}'
ContainerStartReplacement: 'راه‌اندازی کانتینر جایگزین {{ .name }}'
ContainerWaitReplacement: 'بررسی آماده بودن کانتینر جایگزین {{ .name }}'
ContainerStartupDiagnostic: 'عیب‌یابی خطای راه‌اندازی کانتینر جایگزین {{ .name }}'
ContainerRecentLogs: 'گزارش‌های اخیر کانتینر (۲۰۰ خط آخر)'
ContainerDiagnosticLogsFailed: 'دریافت گزارش‌های کانتینر ناموفق {{ .name }} انجام نشد: {{ .err }}'
ContainerDiagnosticLogsEmpty: 'هیچ گزارش کانتینری در دسترس نیست'
ContainerRollbackRestartOld: 'راه‌اندازی دوباره کانتینر اصلی {{ .name }}'
ContainerRollbackRemoveReplacement: 'حذف کانتینر جایگزین ناموفق {{ .name }}'
ContainerRollbackRenameOld: 'بازگردانی نام کانتینر اصلی {{ .name }}'
@@ -653,6 +661,7 @@ XfsNotFound: 'xfs یافت نشد؛ ابتدا xfsprogs را نصب کنید'
# ترمینال
TerminalAIBlockedRiskyCommand: 'دستور پرخطر مسدود شد: {{ .command }}'
TerminalAIThinking: 'هوش مصنوعی در حال فکر کردن است...'
TerminalOutputTruncated: '[خروجی کوتاه شد، فقط خروجی اخیر نمایش داده می‌شود]'
TerminalAIReadyToExecute: 'تفکر کامل شد، برای اجرا Enter را فشار دهید (مدت زمان: {{ .duration }}، توکن‌ها: {{ .tokens }})'
TerminalAIRequestFailed: 'درخواست هوش مصنوعی ناموفق بود: {{ .err }}'
@@ -669,3 +678,44 @@ AIProviderBailian: 'Alibaba Cloud Model Studio'
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
AIProviderArk: 'Volcengine Ark'
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
# Firewall
ErrDockerIptablesChainUnavailable: 'زنجیره DOCKER-USER داکر یافت نشد. داکر را راه‌اندازی مجدد کنید و دوباره تلاش کنید'
ErrDockerNftablesChainUnavailable: 'زنجیره فایروال IPv4 مربوط به nftables داکر یافت نشد. بررسی کنید نسخه فعلی داکر از بک‌اند فایروال nftables پشتیبانی کند، سپس داکر را راه‌اندازی مجدد کرده و دوباره تلاش کنید'
ErrDockerForwardPolicyDrop: 'سیاست پیش‌فرض FORWARD برای {{ .family }} برابر DROP است. آن را تغییر دهید و دوباره تلاش کنید'
ErrUFWRuleAdopt: 'مدیریت قانون UFW ناموفق بود: {{ .detail }}. اگر نسخه فعلی UFW قدیمی‌تر از 0.35 است، ابتدا UFW را ارتقا دهید و دوباره تلاش کنید'
Firewall: 'فایروال'
FirewallTaskHost: 'دیواره آتش میزبان'
FirewallTaskForwarding: 'هدایت پورت'
FirewallTaskDocker: 'محافظت از پورت کانتینر'
FirewallRuleTaskCreate: 'ایجاد قوانین دیواره آتش [{{ .name }}]'
FirewallRuleTaskUpdate: 'به‌روزرسانی قوانین دیواره آتش [{{ .name }}]'
FirewallRuleTaskDelete: 'حذف قوانین دیواره آتش [{{ .name }}]'
FirewallRuleTaskSync: 'همگام‌سازی قوانین دیواره آتش [{{ .name }}]'
FirewallTaskInitialize: 'راه‌اندازی اولیه دیواره آتش [{{ .name }}]'
FirewallCreateRulesStep: 'ایجاد قوانین دیوار آتش'
FirewallCreateRulesResult: 'نتیجه ایجاد: {{ .succeeded }} موفق، {{ .failed }} ناموفق، {{ .skipped }} اجرا نشده'
FirewallRuleOperationResult: 'نتیجه عملیات: {{ .succeeded }} موفق، {{ .failed }} ناموفق'
FirewallCreateRuleSkipped: 'اجرا نشده'
FirewallCreateBatchStep: 'ارسال {{ .count }} قانون به صورت دسته‌ای به {{ .backend }}'
FirewallImportRuleConversion: '[{{ .index }}/{{ .total }}] {{ .source }} → {{ .target }}: {{ .rule }}؛ به {{ .count }} قانون تبدیل شد'
FirewallCreateRuleExecutionFailed: 'ایجاد قانون ناموفق بود. هیچ رکوردی در پایگاه داده ذخیره نشد و دستورات اجراشده بازگردانی نشدند'
FirewallCreateRulePersistenceFailed: 'قانون ایجاد شد، اما ذخیره رکورد مدیریتی آن ناموفق بود'
FirewallAdoptRulePersistenceFailed: 'دستور پذیرش قانون برای مدیریت اجرا شد، اما اطلاعات مدیریت ذخیره نشد'
FirewallSyncOperationsResult: 'عملیات همگام‌سازی: {{ .removed }} حذف‌شده، {{ .created }} ایجادشده، {{ .failed }} ناموفق، {{ .skipped }} اجرا‌نشده، {{ .unchanged }} از قبل مطابق (بدون نیاز به تغییر)'
FirewallSyncRuleUnchanged: 'از قبل مطابق است؛ نیازی به تغییر نیست'
FirewallSyncStep: 'همگام‌سازی قوانین با {{ .name }}'
FirewallSyncFailed: 'همگام‌سازی {{ .failed }} قانون فایروال ناموفق بود'
FirewallResetSourceStep: 'بازنشانی و غیرفعال‌کردن فایروال مبدأ {{ .name }}'
FirewallResetSourceResult: 'فایروال مبدأ بازنشانی شد و {{ .removed }} قانون پایگاه داده حذف شد'
FirewallInitializeChainsStep: 'راه‌اندازی و اتصال زنجیره‌های پایه {{ .name }}'
FirewallRestoreRulesStep: 'بازیابی قوانین پایگاه داده در {{ .name }}'
FirewallSyncWhitelistStep: 'همگام‌سازی فهرست مجاز پورت‌های فایروال'
FirewallEnableForwardingStep: 'فعال‌سازی و اتصال زنجیره‌های هدایت پورت'
FirewallRestoreForwardingRulesStep: 'بازیابی قوانین هدایت پورت پایگاه داده'
FirewallInspectDockerGuardStep: 'بررسی پشتیبان فایروال Docker و سیاست‌ها'
FirewallInitializeDockerGuardStep: 'راه‌اندازی و اتصال زنجیره‌های محافظت پورت {{ .name }}'
FirewallPersistDockerGuardStep: 'ذخیره وضعیت محافظت پورت Docker'
ErrFirewallRuleScopeChange: "فایروال فعلی از تغییر محدودهٔ قانون (مانند خانوادهٔ آدرس IPv4/IPv6) پشتیبانی نمی‌کند. لطفاً یک قانون جدید ایجاد کنید."
FirewallWhitelistReleased: "{{ .name }}: حفاظت فهرست مجاز برداشته شد؛ قانون اجازه حفظ می‌شود. برای بستن پورت، قانون را به‌صورت دستی از فهرست قوانین حذف کنید"
FirewallWhitelistRequired: "{{ .name }}: توسط قوانین پورت‌های ضروری سیستم محافظت می‌شود"

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