Compare commits

..
2218 Commits
Author SHA1 Message Date
Codex 71dd21269b Merge upstream monitoring features preserving domain extensions and require fixed Go toolchain 2026-10-22 23:50:03 +08:00
naiba a20c79b75c chore: upgrade frontend 2026-10-22 17:22:21 +08:00
naiba 2bd1a86429 chore: upgrade frontend 2026-10-22 16:22:26 +08:00
naibaandnaiba/CloudCode efd3e07b90 fix(service): skip retained history for unavailable monitors at startup
Ignore history rows without initialized daily or monthly state so deleted and quarantined services cannot panic dashboard startup. Cover startup and statistics preservation with regression tests.

Thanks to Zachariah Mithani (@zachariah-mithani, AploSec) for privately reporting the retained service history startup panic.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-10-21 13:31:26 +00:00
naibaandnaiba/CloudCode b329755836 docs: add feepay sponsor
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-10-17 07:13:01 +00:00
naibaandnaiba/CloudCode 29f1568988 docs(security): add vulnerability reporting email
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-10-16 14:31:38 +00:00
github-actions[bot] 05baa2f331 update contributors[no ci] 2026-10-16 19:25:38 +08:00
Sam5440andnaiba/CloudCode 8721a15b12 feat(server): online session history with configurable stats window (#1243)
Add persisted online sessions, configurable retention and online history statistics with lifecycle, recovery, timezone and concurrency regression coverage.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-10-16 19:25:13 +08:00
naiba 9881c47f0c chore: upgrade frontend 2026-10-04 20:03:52 +08:00
naiba ec637a688c docs: replace sponsors with placeholder 2026-10-01 16:37:10 +00:00
Krisandnaiba 8328047aa8 fix(alerts): evaluate fresh reports and order notification delivery (#1242)
* fix(alerts): evaluate fresh reports and make notification transitions reliable

* fix(alerts): identify equal-timestamp reports by sequence

* fix(alerts): preserve notification transition semantics

---------

Co-authored-by: naiba <hi@nai.ba>
2026-10-02 00:31:32 +08:00
naiba 1420dd450c fix(nat): bound dashboard credential classification 2026-10-01 11:48:23 +00:00
github-actions[bot] 34ac50394d update contributors[no ci] 2026-10-01 19:26:01 +08:00
naiba b71188f104 Merge pull request #1241 from xvkong233/fix/nat-selective-authorization-strip
fix(nat): strip only dashboard-issued Authorization credentials at NAT ingress
2026-10-01 19:25:36 +08:00
xvkong233 3ad985f36e fix(nat): scrub dashboard-issued credentials from all accepted token channels
The initial selective strip covered only the Authorization header, but the
dashboard also accepts its JWT via the ?token= query parameter and the
nz-jwt cookie (jwt.go TokenLookup channels). A dashboard credential arriving
through those channels would still reach a NAT backend.

Apply the same classify-then-strip rule to all three channels at NAT
ingress: values verified as dashboard-issued are removed, foreign values
are preserved byte-for-byte. Adds a ServeNAT flow test exercising the live
classifier and a wiring test pinning the gate injection in main().
2026-10-01 11:17:45 +00:00
xvkong233 40240ed02b fix(nat): strip only dashboard-issued Authorization credentials at NAT ingress
Since 2640b86 (v2.2.11, 2026-07-20) the NAT ingress deleted the entire
Authorization header, breaking self-hosted apps behind the tunnel that
authenticate with their own Bearer tokens (e.g. the Qinglong panel login
loop).

Strip only credentials issued by this dashboard and forward foreign ones:

- controller.IsDashboardCredential classifies an Authorization value as
  dashboard-issued via a signature-only JWT parser derived from the auth
  middleware (same key, HS256 pinned, claims validation deliberately
  skipped so an expired panel token is still recognized and kept out of
  the tunnel) or an api_tokens lookup for PAT-shaped values. Unknown PATs,
  foreign signatures, other schemes and missing values pass through. When
  classification cannot complete (parser not ready, DB nil or error) the
  verdict fails closed (strip) and the log carries a fixed reason only,
  never credential content.
- Both NAT capability variants (default, and agent-compat legacy /
  invalid-capability / post-consume paths) replace the unconditional
  Del("Authorization") with stripDashboardCredential. The rpc package
  receives the classifier through SetNATDashboardCredentialGate, the same
  injection pattern as SetMCPKillSwitchObserver; an unwired gate keeps the
  historical strip-everything behavior.

Tests: controller classification semantics (panel JWT, expired panel JWT
and panel PAT stripped; foreign-signed JWT, unknown nzp_ token, random
strings, non-Bearer schemes and missing header pass through; parser
unavailable, DB nil and DB error fail closed with credential-free logs)
plus rpc unit and flow coverage for both variants (dashboard credentials
never reach the forwarded bytes; foreign and missing Authorization are
forwarded untouched).

Compatibility: no API, database or frontend changes; NAT backends that do
not use Authorization behave exactly as before.
2026-10-01 11:17:45 +00:00
naiba 36b742a7e9 fix: harden dashboard security boundaries 2026-10-01 04:28:34 +00:00
naiba c726930833 chore: update frontend 2026-09-26 10:26:05 +08:00
naiba 9dab6a5039 fix: harden notification cache synchronization 2026-09-19 10:44:17 +00:00
github-actions[bot] 589f01f562 update contributors[no ci] 2026-09-14 11:23:19 +08:00
naiba e202c2d9ca Merge pull request #1237 from 77-QiQi/master
Improve: Delete the corresponding DNS record if the IPv4/IPv6 address is empty.
2026-09-14 11:22:52 +08:00
77-QiQi fa611ebd9e Update ddns_test.go 2026-09-14 11:06:44 +08:00
77-QiQi 39e1c6d1be Update ddns_test.go 2026-09-14 10:57:37 +08:00
77-QiQi c19e8eb252 Update ddns_test.go 2026-09-14 10:51:01 +08:00
77-QiQi db78c34b91 Update ddns_test.go 2026-09-14 10:43:36 +08:00
77-QiQi e9ddd46655 Update ddns_test.go 2026-09-14 10:36:04 +08:00
77-QiQi 09db8b4361 Update ddns_test.go 2026-09-14 10:29:08 +08:00
77-QiQi ed9f21e4ea Update ddns_test.go 2026-09-14 10:13:10 +08:00
77-QiQi f611f0a551 Update ddns_test.go 2026-09-14 10:04:38 +08:00
77-QiQi 19d959c60d Update ddns.go 2026-09-14 10:02:03 +08:00
77-QiQi 02fe54bb80 Update ddns_test.go 2026-09-14 09:10:17 +08:00
77-QiQi 0a71cbdb63 Update ddns_test.go 2026-09-14 08:56:04 +08:00
77-QiQi dd0b598dc3 Update ddns_test.go 2026-09-14 08:50:08 +08:00
77-QiQi 892619c95c Update ddns_test.go 2026-09-14 08:39:52 +08:00
77-QiQi cbc583dd3a Update ddns.go 2026-09-14 08:39:22 +08:00
77-QiQi f2f10ea7bc Update ddns.go 2026-09-13 20:55:03 +08:00
77-QiQi b18e085a2c Merge branch 'nezhahq:master' into master 2026-09-13 20:53:20 +08:00
naiba 06d387779d fix(tsdb): survive low disk space 2026-09-13 01:15:18 +00:00
github-actions[bot] 1155c17573 update contributors[no ci] 2026-09-12 22:59:36 +08:00
NikoCat233 d19952a834 feat(auth): allow JWT IP changes by configuration (#1236)
* feat(auth): allow JWT IP changes by configuration

* fix(auth): make JWT IP change setting concurrency-safe

* test(auth): move JWT IP change setting tests out of MCP test file
2026-09-12 22:59:17 +08:00
naiba 46d83d6d68 style(ddns): apply gofmt 2026-09-12 13:29:44 +00:00
naiba 8cb86e3853 chore(frontend-templates): update frontend versions 2026-09-12 11:11:22 +00:00
github-actions[bot] 5d5830fef3 update contributors[no ci] 2026-09-12 18:56:54 +08:00
Ryrenz f366677744 feat: surface GPU memory in host state (#1235)
- Add State_GPU message to the proto with utilization and memory fields
- Add HostState.GPUs with conversion in both PB() and PB2State()
- Keep the existing gpu field populated so older agents keep working

Regenerated with protoc-gen-go v1.34.2 / protoc v5.29.3 to match the
existing files; nezha_grpc.pb.go is untouched since the service is
unchanged.

Closes #1217
2026-09-12 18:56:35 +08:00
77-QiQi c684388153 Improve: Delete the corresponding DNS record if the IPv4/IPv6 address is empty. 2026-09-08 12:07:23 +08:00
Bot 5665e0aaa1 fix(agentcompat): decouple auto-lockdown and telemetry-only mode under agentcompat build tag 2026-09-04 19:13:27 +08:00
Bot 5d0da5490c fix(ci): allow server transfer task in telemetry-only mode and align authz reconnect test 2026-09-04 18:46:58 +08:00
Buri 8c481985c2 feat: enforce telemetry-only mode and safe agent decommission lifecycle 2026-09-04 18:17:52 +08:00
naiba eb48a178f8 ci: use latest compatible Go patch 2026-09-02 13:45:05 +00:00
naiba f2e8d03667 ci: pin Go 1.26.6 2026-09-02 13:35:11 +00:00
naiba d6d5b9804d chore(frontend-templates): update frontend versions 2026-09-02 13:30:46 +00:00
naiba 19daf4ad3c fix(alert): prevent persisted rule crash loops 2026-09-02 13:30:46 +00:00
naiba 8f1147c800 chore(deps): update direct dependencies 2026-09-02 13:30:46 +00:00
Bot 3d2b27f4ec feat: custom domain management, config and notification fixes, and VPS auto-renewal rollover 2026-09-01 23:45:21 +08:00
naiba fffbe68650 docs: add AxisNow sponsor 2026-09-01 08:16:45 +00:00
naiba b523961cee docs: add sponsors to README 2026-08-31 07:05:06 +00:00
Bot 00f5777112 Merge branch 'upstream/master' into master and preserve domain extensions 2026-08-31 02:46:42 +08:00
github-actions[bot] 1405ba76e5 update contributors[no ci] 2026-08-17 11:55:03 +08:00
77-QiQi 28d731265c improve: skip empty addresses during DDNS updates (#1231) 2026-08-17 11:54:39 +08:00
naiba e88650281b ci(release): support registries without attestations 2026-08-15 05:37:12 +00:00
naiba a4f8eaf0ff chore(frontend-templates): update frontend versions 2026-08-15 05:20:28 +00:00
naiba 38824dbc11 fix(security): restrict service monitors to probe tasks 2026-08-15 05:00:28 +00:00
Weblate (bot)andace-consultoria 42d9e4c8c3 Translated using Weblate (Portuguese (Brazil)) (#1228)
Currently translated at 95.0% (57 of 60 strings)


Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/pt_BR/
Translation: Nezha/Nezha Dashboard

Co-authored-by: ace-consultoria <erec.novais@aceconsultoria.org>
2026-08-15 09:34:18 +08:00
naiba c3c165c146 fix(terminal): bound websocket input frames 2026-08-12 02:27:12 +00:00
naiba 71d43c6a6e chore(frontend-templates): update template versions 2026-08-11 14:23:41 +00:00
naiba d1fcde8e9e fix(security): block IPv6 transition ranges for webhooks 2026-08-11 14:23:14 +00:00
github-actions[bot] 27e0a89aa8 update contributors[no ci] 2026-08-11 22:07:37 +08:00
Chraandnaiba c6082abc25 fix: 修复空周期流量规则列表导致流量历史清理失效 (#1226)
* fix: prevent unbounded transfer history growth

* fix: preserve transfer history on alert load errors

* test: close transfer cleanup database

---------

Co-authored-by: naiba <hi@nai.ba>
2026-08-11 22:07:17 +08:00
github-actions[bot] 511a3e8bf5 update contributors[no ci] 2026-08-08 16:54:27 +08:00
naiba 99e19c551a chore(i18n): reconnect Hosted Weblate history 2026-08-08 08:53:18 +00:00
ace-consultoria 45af3d80a3 Added translation using Weblate (Portuguese (Brazil)) 2026-08-08 08:53:18 +00:00
Hosted WeblateandA b2c09443b3 Added translation using Weblate (Romanian)
Co-authored-by: A <cdparanoia@users.noreply.hosted.weblate.org>
2026-08-08 08:53:18 +00:00
github-actions[bot] 5eda3e8c5d update contributors[no ci] 2026-08-07 15:58:55 +08:00
Mubelotix 71e2cbcfea Fixes broken Star History chart
This new provider revives the OG service that's being killed by Github
2026-08-07 15:58:29 +08:00
ace-consultoria 7b8b05b464 Added translation using Weblate (Portuguese (Brazil)) 2026-08-06 15:29:57 +02:00
github-actions[bot] 775b8bedac update contributors[no ci] 2026-08-04 18:42:13 +08:00
Kris 6f7ce9fdec Add Trendshift badge to README (#1224) 2026-08-04 18:41:51 +08:00
naiba eba91d2883 chore: update frontend 2026-08-03 20:17:37 +08:00
naiba a9d14a0dfa chore(frontend-templates): add Nezha-Pixel template 2026-08-01 20:21:57 +08:00
naiba f9e7cfa54b chore(frontend): update bundled template versions 2026-08-01 20:21:57 +08:00
naiba 4776dc9ec6 chore(deps): update direct Go dependencies 2026-08-01 20:21:56 +08:00
naiba 6c5317ba93 fix(security): correct stream quota advisory reference 2026-08-01 20:21:56 +08:00
github-actions[bot] ae87e73f98 update contributors[no ci] 2026-08-01 15:45:40 +08:00
9ec6164f58 fix(security): harden server and service deletion lifecycle (#1220)
* test: TDD regression tests for GHSA-jx78-55p5-rwv5 stream quota enforcement

* Apply remaining changes

* fix: update action SHA allowlist and test assertions to match dependabot bump

* fix: close GHSA-jx78-55p5-rwv5 incomplete fix of GHSA-qjpp-gffx-2wm9

Finding 1 (Moderate): nil-guard reporterServer in delayCheck and notifyCheck.
ServerShared has its own lock independent of serviceResponseDataStoreLock, so
m := ServerShared.GetList() taken inside the worker can return a nil entry for
the reporter if the server was concurrently deleted. Previously this caused an
unrecovered SIGSEGV in the worker goroutine (and in the gRPC layer with no
recovery interceptor), taking down the whole instance.

Finding 2 (Low): nil-guard ss.services[id] in ServiceSentinel.Delete().
A caller-supplied id that is absent from the registry caused
ss.services[id].CronJobID to panic, aborting the Delete loop and leaving every
subsequent valid id as a zombie service (DB row deleted, in-memory entry kept,
cron probe still running).

Regression tests added for both findings following the existing
servicesentinel_lifecycle_test.go patterns.

* Apply remaining changes

* chore: replace commit hashes with version tags in test.yml

* fix(server): serialize authoritative lifecycle changes

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(service): bind reports to reporter lifecycle

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(rpc): reject results from stale task streams

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(agentcompat): allow version-tagged actions

* fix(agentcompat): allow literal checkout refs

* refactor(agentcompat): remove SHA resolver policy

* test(agentcompat): remove resolver SHA fixtures

* test(agentcompat): remove mutable ref fixtures

* test(agentcompat): use tagged actions in secure fixtures

* test(agentcompat): update credential fixtures for tags

* test(agentcompat): update reusable action fixtures

* test(agentcompat): update artifact redaction fixtures

* test(agentcompat): finish artifact fixture tag migration

* test(agentcompat): update workflow validation fixtures

* test(agentcompat): update dependency workflow fixture

* ci(agentcompat): stop pinning cross-repository revisions

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: naiba <hi@nai.ba>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-01 15:45:20 +08:00
dependabot[bot] bb941e4d73 chore(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-go](https://github.com/actions/setup-go).


Updates `actions/checkout` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v7...v7.0.1)

Updates `actions/setup-go` from 6 to 7
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 06:56:52 +08:00
github-actions[bot] 9f080a9db5 update contributors[no ci] 2026-07-21 21:12:59 +08:00
naibaandnaiba/CloudCode 5d7e8b58af chore(frontend): update bundled template versions
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-21 13:11:43 +00:00
naibaandnaiba/CloudCode c893aa5786 fix(service): serialize reports with service lifecycle
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-21 13:11:05 +00:00
naibaandnaiba/CloudCode 77c298fa80 chore(deps): bump libdns-tencentcloud
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-21 02:42:47 +00:00
naibaandnaiba/CloudCode 479fcac11c test(agentcompat): wire FD diagnostics into stress
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 22:11:21 +00:00
naibaandnaiba/CloudCode 11eb339540 test(agentcompat): exercise real FD lifecycle diagnostics
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 22:10:29 +00:00
naibaandnaiba/CloudCode b204e3dda4 test(agentcompat): verify FD diagnostic collection
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 22:09:39 +00:00
naibaandnaiba/CloudCode 970d2ae672 test(agentcompat): model FD diagnostic lifecycles
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 22:08:01 +00:00
naibaandnaiba/CloudCode 1e67f4017a test(agentcompat): preserve default sampler evidence
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 22:07:09 +00:00
naibaandnaiba/CloudCode e78af86164 test(agentcompat): capture FD observations in process samples
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 22:06:19 +00:00
naibaandnaiba/CloudCode 8b352484d8 test(agentcompat): preserve existing CRLF workflow data
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:32:49 +00:00
naibaandnaiba/CloudCode 926f2ca632 chore(agentcompat): document intentional scanner findings
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:07:04 +00:00
naibaandnaiba/CloudCode 0332b1e212 fix(agentcompat): root workflow file reads
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:06:40 +00:00
naibaandnaiba/CloudCode 24c7a898cf test(agentcompat): cover CRLF workflow mutations
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:06:14 +00:00
naibaandnaiba/CloudCode 153dfeb569 fix(agentcompat): retain transfer sentinel handles
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:05:52 +00:00
naibaandnaiba/CloudCode 393f49aa59 fix(agentcompat): retain reconnect sentinel handles
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:05:29 +00:00
naibaandnaiba/CloudCode 20930aaf9f fix(agentcompat): root filesystem guard reads
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:05:10 +00:00
naibaandnaiba/CloudCode 23763c8210 fix(agentcompat): secure legacy file manager boundaries
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:04:52 +00:00
naibaandnaiba/CloudCode 029d458f50 fix(agentcompat): root scenario config reads
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:04:34 +00:00
naibaandnaiba/CloudCode e157304475 fix(agentcompat): root workspace log creation
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:04:16 +00:00
naibaandnaiba/CloudCode 9bc3068d14 fix(agentcompat): resolve trusted Go executable
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:03:55 +00:00
naibaandnaiba/CloudCode e20197fdcc fix(agentcompat): root proc resource reads
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:03:37 +00:00
naibaandnaiba/CloudCode 57f9564ce1 fix(agentcompat): decode inotify events safely
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:03:20 +00:00
naibaandnaiba/CloudCode b92ab9a60f fix(agentcompat): snapshot evidence through rooted handles
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:02:58 +00:00
naibaandnaiba/CloudCode 454c577968 fix(agentcompat): secure credentialed agent workspaces
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:02:37 +00:00
naibaandnaiba/CloudCode c7799854b9 fix(agentcompat): validate supervised executable paths
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 17:02:21 +00:00
naibaandnaiba/CloudCode 8a0382478b test(agentcompat): lock Agent stress preparation
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:16:31 +00:00
naibaandnaiba/CloudCode 396a0fcb21 ci: prepare dashboard inputs for stress
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:16:00 +00:00
naibaandnaiba/CloudCode 620bebbf8e test(agentcompat): find repository root across platforms
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:15:31 +00:00
naibaandnaiba/CloudCode 589b06b63d test(agentcompat): probe credential execution support
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:15:10 +00:00
naibaandnaiba/CloudCode dcab0d2245 fix(agentcompat): classify Windows absolute paths
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:14:37 +00:00
naibaandnaiba/CloudCode 866ddddef1 test(agentcompat): make dedicated fixtures platform-native
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:13:41 +00:00
naibaandnaiba/CloudCode 3bdbfd2593 test(agentcompat): use native validation paths
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:13:41 +00:00
naibaandnaiba/CloudCode ccfee605bf test(agentcompat): use native evidence metadata paths
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:13:08 +00:00
naibaandnaiba/CloudCode a60924b6c7 test(agentcompat): use native contract paths
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:12:38 +00:00
naibaandnaiba/CloudCode 56671d5509 test(agentcompat): define Windows evidence mode semantics
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:11:44 +00:00
naibaandnaiba/CloudCode 1c881ed6d2 test(agentcompat): isolate Unix evidence mode checks
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:11:16 +00:00
naibaandnaiba/CloudCode ffbb8bed09 fix(agentcompat): validate evidence modes by platform
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:10:40 +00:00
naibaandnaiba/CloudCode 381eee0cec fix(agentcompat): constrain command harness to Linux
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 14:09:20 +00:00
naibaandnaiba/CloudCode 5d7fef6408 test(agentcompat): isolate stress resource sampling
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 08:04:17 +00:00
naibaandnaiba/CloudCode a7cf600bd7 test(agentcompat): configure connection count sampling
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 08:02:45 +00:00
naibaandnaiba/CloudCode 02ccdf038c test(agentcompat): expose connection count fixture option
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 08:02:19 +00:00
naibaandnaiba/CloudCode d96b34b0d4 ci: require agentcompat stress validation
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:55:20 +00:00
naibaandnaiba/CloudCode d501f23473 test(agentcompat): add exact stress scenario
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:54:03 +00:00
naibaandnaiba/CloudCode 64daa6e9ae test(agentcompat): add scenario CLI wiring
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:52:28 +00:00
naibaandnaiba/CloudCode 9af3720bf0 test(agentcompat): add integration scenarios
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:51:20 +00:00
naibaandnaiba/CloudCode b3b3d92895 test(agentcompat): add evidence validation
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:50:03 +00:00
naibaandnaiba/CloudCode 1c3e926819 test(agentcompat): add protocol clients
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:48:15 +00:00
naibaandnaiba/CloudCode 8a0e84b214 test(agentcompat): add dashboard runtime harness
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:46:55 +00:00
naibaandnaiba/CloudCode 2a3245c412 test(agentcompat): add agent runtime harness
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:45:05 +00:00
naibaandnaiba/CloudCode 3a9e0c7887 test(agentcompat): add process supervision harness
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:43:28 +00:00
naibaandnaiba/CloudCode 0543995ec3 test(agentcompat): harden deterministic fixtures
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:42:03 +00:00
naibaandnaiba/CloudCode fcf58b0b4b test(agentcompat): define integration contracts
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:39:48 +00:00
naibaandnaiba/CloudCode 391f622c75 test(agentcompat): cover SQLite hold arbitration
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:37:59 +00:00
naibaandnaiba/CloudCode 52c766d27c test(rpc): cover reentrant state observers
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:36:50 +00:00
naibaandnaiba/CloudCode aee586baad feat(agentcompat): add dashboard listener hooks
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:33:38 +00:00
naibaandnaiba/CloudCode 3f6de265a0 fix(mcp): harden dashboard dispatch lifecycle
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:32:16 +00:00
naibaandnaiba/CloudCode 2640b86d3a feat(agentcompat): expose dashboard capability routes
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:30:50 +00:00
naibaandnaiba/CloudCode 0c6eb416a5 feat(agentcompat): add SQLite hold scoped attribution
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:29:21 +00:00
naibaandnaiba/CloudCode 26e92da33b feat(agentcompat): add scoped IO stream capabilities
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:27:33 +00:00
naibaandnaiba/CloudCode c756ef9385 fix(rpc): make IO stream lifecycle race-safe
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:26:19 +00:00
naibaandnaiba/CloudCode 8b47ff141f feat(agentcompat): add shared runtime contracts
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-20 04:25:03 +00:00
naibaandnaiba/CloudCode a416e3bf29 test(compat): complete NAT fixture shutdown
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-14 06:13:57 +00:00
naibaandnaiba/CloudCode be9af6fcb3 test(compat): harden fixture containment cleanup
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-14 06:12:00 +00:00
naibaandnaiba/CloudCode 92a1cc76be test(compat): add deterministic local fixtures
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-14 05:49:15 +00:00
naibaandnaiba/CloudCode b01ff45ef4 chore(frontend-templates): bump user-dist v2.4.1 and aobobo-dist v1.4.6
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-12 06:27:58 +00:00
github-actions[bot] 40a688f7cb update contributors[no ci] 2026-07-04 19:09:29 +08:00
naibaandnaiba/CloudCode 1477138dad chore(frontend-templates): bump admin-dist v2.2.5, user-dist v2.3.2 and aobobo-dist v1.4.5
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-07-04 11:08:03 +00:00
Bot c778472571 fix: panic on nil pointer dereference in notification sending and fix date parsing formats 2026-07-01 23:36:21 +08:00
Bot cabbfff858 fix: correctly parse VPS expiry from PublicNote and enhance domain date parsing 2026-07-01 23:05:34 +08:00
naibaandnaiba/CloudCode fd31e5f1b0 chore(frontend-templates): add aobobo template
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-06-28 01:09:23 +00:00
naibaandnaiba/CloudCode e1045ffd30 fix(api): redact profile password hash
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-06-28 01:09:23 +00:00
github-actions[bot] 015c49c8e3 update contributors[no ci] 2026-06-27 11:30:20 +08:00
naiba 6aab2e4919 Merge pull request #1213 from nezhahq/dependabot/github_actions/github-actions-02325a8da5
chore(deps): bump the github-actions group with 2 updates
2026-06-27 11:30:04 +08:00
dependabot[bot] 94a70a668b chore(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/cache](https://github.com/actions/cache).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

Updates `actions/cache` from 5 to 6
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-23 16:14:34 +00:00
naibaandnaiba/CloudCode d750fa035d chore(frontend-templates): bump user-dist v2.3.1
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-06-20 11:01:54 +00:00
naiba 39d398066d fix(api): redact third-party credentials in ddns/notification list
GET /api/v1/ddns and /api/v1/notification returned full objects with
plaintext credentials (Cloudflare/TencentCloud secrets, webhook URLs
with embedded bot tokens, Authorization headers). Redact these fields
in the list responses.

Since the frontend edit form repopulates from the list endpoint, the
update handlers now treat an empty submitted credential as "no change"
and preserve the stored value, preventing accidental secret wipes.

Ref: GHSA-ww5p-j6cj-6mqq
2026-06-20 01:54:50 +00:00
naiba d556c3216f chore(frontend-templates): bump user-dist v2.3.0 and nazhua-dist v1.1.0 2026-06-20 00:40:38 +00:00
naiba 8cd2da79b8 chore(frontend-templates): bump admin-dist v2.2.4 2026-06-20 00:39:47 +00:00
github-actions[bot] d9ae9cc772 update contributors[no ci] 2026-06-20 08:25:32 +08:00
naiba 874a47c32c Merge pull request #1211 from hamster1963/fix-service
Filter service stats based on user permissions
2026-06-20 08:25:15 +08:00
hamster1963 7a61da098b fix(service): filter service stats based on user permissions 2026-06-20 00:45:53 +08:00
naiba 3d74cd9431 fix(alert): retain Duration samples for offline rules so they fire
RetentionWindow() trimmed offline-rule samples to 1, but Check's offline
branch reads points[len-Duration:] and needs Duration samples. The window
never filled, boundCheck always returned "passed", and offline alerts never
fired a notification — while other alert types worked. Offline rules now
retain Duration samples (cycle rules still keep 1, matching their last-sample
lookback).

Fixes the prior over-correction in RetentionWindow; corrects the test that
had pinned the buggy offline=1 value, and adds offline + combined-rule
regression tests driving the real trim loop.
2026-06-08 00:57:14 +00:00
naiba 576e6773c0 fix(release): bump user-dist to v2.1.2 (v2.1.1 never released upstream)
The v2.2.2 Release workflow failed: fetch-frontends.sh fetched user-dist
from hamster1963/nezha-dash-v2 at v2.1.1, but upstream went v2.1.0 -> v2.1.2
and never tagged v2.1.1, so the dist.zip download 404'd and unzip aborted the
build. Point at the existing v2.1.2 asset.
2026-06-07 16:23:24 +00:00
naiba b12b1709d9 test(alert): pin sample-slice memory-boundedness invariant
Add TestCheckStatus_SampleMemoryBounded asserting that, across 100k ticks,
the per-(alert,server) sample slice length and capacity stay bounded by the
rule's retention window and never grow with elapsed time.
2026-06-07 16:14:31 +00:00
naiba e756b4540c fix(alert): keep sample history by rule-defined retention window
Trim alert samples using AlertRule.RetentionWindow() (max rule duration)
instead of Check()'s verdict max, which is 0 while a rule's window is still
filling. The old max<=0 trim wiped history every tick, so Duration>=2 general
rules never accumulated enough samples and never fired a notification.

Add model + end-to-end singleton regression tests covering sample
accumulation, the retention-window contract, and the actual notify path.
2026-06-07 16:11:43 +00:00
naiba 66de244c2e feat(oauth2): add dashboard_host setting for callback URL
Decouple OAuth2 callback host from agent install host. When dashboard_host
is empty, the request Host is passed through; otherwise non-reserved hosts
are pinned to dashboard_host. Added to reserved-host allowlist for NAT.
2026-06-06 05:03:12 +00:00
naiba c595728609 chore(frontend-templates): bump admin-dist v2.2.2 and user-dist v2.1.1 2026-06-06 04:55:31 +00:00
dependabot[bot] d8d8729ecb chore(deps): bump the github-actions group with 11 updates (#1198) 2026-06-05 11:01:02 +08:00
naiba 6e9510d26a chore(frontend-templates): bump admin-dist v2.2.0 2026-06-05 02:42:58 +00:00
naiba 5fd91c44b0 test(rpc): cover both-key-styles present in agent auth metadata
After #1197/#244 a new agent emits both hyphenated and underscore credential
metadata, so a new dashboard receives both at once. Add tests for the everyday
both-present case and pin that the hyphenated key takes precedence over the
underscore alias that Caddy v2.11.4 strips.
2026-06-05 02:42:58 +00:00
naiba 0759f5c801 fix(alert): prevent divide-by-zero panic and unbounded growth on zero-duration rule
A general alert rule with Duration:0 (accepted by the API, no minimum
validation) drove fail*100/total with total==0, panicking with an integer
divide-by-zero inside checkStatus, which has no recover and would take down the
whole alert goroutine — a config-reachable DoS by any user able to create alert
rules. Skip duration<=0 rules in Check via continue (not boundCheck, which would
pollute hasPassedRule and skip sibling valid rules). Also trim the sample slice
when max<=0 in alertsentinel, otherwise it appended every tick without ever
trimming. Adds regression tests for zero-only and mixed rule sets.
2026-06-05 02:42:49 +00:00
naiba 36240f5888 fix(rpc): cap concurrent IO streams per user and per server
GHSA-jg62-j5h6-8mpq: the terminal and file-manager endpoints created unbounded
IO streams; an authenticated member could open thousands, each spawning
goroutines, a 1MiB buffer and an agent-side PTY, exhausting dashboard and agent
resources. CreateStream now enforces a per-user (20) and per-server (40) cap in
the existing ioStreamMutex critical section, using the stream map as the single
source of truth. Dashboard-internal streams (uid==0: NAT, server transfer, MCP
transfer) skip the per-user cap but still count per-server. Adds caps,
exemption, slot-release and no-leak regression tests.
2026-06-05 02:42:38 +00:00
naiba fb5c37e983 fix(oauth2): validate Host header against allowlist for callback URL
GHSA-9rc6-8cjv-rcvx: getRedirectURL derived the OAuth2 callback URL from the
raw Host header, so a forged Host (or a provider with loose redirect-URI
matching) could divert a victim's authorization code to an attacker origin and
bind their identity. Trust the request Host only when it is an operator-declared
dashboard host (IsReservedDashboardHost, same allowlist guarding NAT routing);
otherwise fall back to the configured InstallHost.
2026-06-05 02:42:28 +00:00
naiba 1927b0c6a4 chore(ci): add dependabot version updates for gomod and actions
Repo only had Dependabot security updates (reactive). Add weekly grouped
version updates for Go modules and GitHub Actions to keep dependencies
current and reduce CVE accumulation.
2026-06-05 01:08:00 +00:00
naiba 78f5b014ee fix(csrf): set Secure on csrf cookie only over HTTPS
CodeQL go/cookie-secure-not-set (CWE-614) flagged the nz-csrf cookie as
missing the Secure attribute. Mirror writeOauth2StateCookie and derive
Secure from the request scheme instead of hardcoding it: forcing
Secure=true would make browsers drop the cookie on plain-HTTP intranet
deployments, breaking the double-submit CSRF pair and 403-ing every
unsafe request.
2026-06-05 01:08:00 +00:00
github-actions[bot] 806036d75b update contributors[no ci] 2026-06-05 09:07:02 +08:00
zhdsmy 4289d30357 fix(auth): accept hyphenated agent metadata (#1197) 2026-06-05 09:06:38 +08:00
naiba c3c361ad66 fix(service): flip CopyStats public filter to HideForGuest
Completes the EnableShowInService->HideForGuest rename; the public stats
filter was missed in the previous commit, leaving service/singleton
referencing the removed field and breaking the build.
2026-06-02 03:32:10 +00:00
naiba e47b5b99f8 chore(frontend-templates): bump admin-dist v2.1.2 and user-dist v2.1.0 2026-06-02 03:31:36 +00:00
naiba 8285a3d99f docs(mcp): note server.exec reaps the whole process tree
Document in the tool description and protocol comment that the agent
kills the entire process group/JobObject on return or timeout, so
background jobs must fully detach (setsid/screen/tmux/systemd-run).
2026-06-02 03:31:36 +00:00
naiba 836f1db4b7 feat(service): replace EnableShowInService with HideForGuest
Mirror the server HideForGuest flag on services: rename the field
(dropping the gorm default), invert userCanViewService so a service is
visible to guests by default and hidden only when HideForGuest is set,
and flip the public-stats filter in servicesentinel accordingly. Update
the visibility and permission-matrix tests to the new semantics.
2026-06-02 03:31:36 +00:00
naiba bb38a9d4d2 fix(mcp): accept agents reporting version without v prefix and stop masking tool errors
compareSemver fell back to lexical string ordering when the numeric
segments were equal, so an agent reporting "2.1.0" compared as older than
the gate constant "v2.1.0" (0x32 < 0x76). This wrongly flagged every agent
as unsupported_agent, breaking server.exec and fs.* on all online servers.
Drop the fallback: semverParts already strips the v prefix, so equal
numeric segments mean equal versions.

The error was further masked because tool error responses shipped a
structuredContent {error_code,error} that violates the tool outputSchema
(which requires exit_code/stdout/...). Strict MCP clients validated it and
rejected the whole response with -32602, hiding the real isError text.
Omit structuredContent on error; the cause stays in content[].text.
2026-05-31 16:18:57 +00:00
naiba 9b5199ac6c chore(frontend-templates): bump admin-frontend to v2.1.1 2026-05-31 15:14:48 +00:00
naiba 083bc985c5 feat(auth): split inventory scope out of server scope
Carve a new nezha:inventory:{read,delete,*} resource family out of
nezha:server:*. Listing and deleting servers/server-groups (GET /server,
/server-group, /ws/server, batch-delete/server[-group], MCP server.list)
now require nezha:inventory:*, while nezha:server:* covers per-server
runtime operations (get, exec, fs, config, metrics, batch-move,
force-update).

Also declare MCP tool OutputSchema for exec/fs/meta/server/transfer and
wrap server.list output in {servers,count} so strict MCP clients accept
the structured result. Correct the /file all-of scope entry and the stale
server:read documentation.
2026-05-31 15:14:41 +00:00
naiba f7f8264ec0 fix(auth): always serialize User.Role so admin (role 0) is not omitted
The Role field used json:"role,omitempty". Admin is RoleAdmin = 0, so an
admin profile serialized without a `role` key. The admin-frontend gates the
admin menu (user management, settings) on `role === 0`, and its normalizeRole
helper defaults a missing role to non-admin, so admins lost the admin menu.

Drop omitempty so role 0 is always sent. Add a regression test.
2026-05-31 12:05:11 +00:00
naibaandcloudcode 34ab8a31bc chore(frontend-templates): bump admin-frontend to v2.1.0
Embed the admin-frontend v2.1.0 release (PAT UI, CSRF handling, reserved
hosts) so the upcoming backend v2.1.0 ships the matching frontend.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 10:26:41 +00:00
naibaandcloudcode 7432efde04 chore(frontend-templates): bump user-frontend to v2.0.4
Upstream hamster1963/nezha-dash-v2 v2.0.4 reviewed commit-by-commit for
supply-chain risk (dependency bumps only, no new packages/postinstall
scripts/network calls/obfuscation) and mirrored to nezhahq/user-frontend-backup.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 09:49:57 +00:00
Hosted WeblateandA 4b92934af4 Added translation using Weblate (Romanian)
Co-authored-by: A <cdparanoia@users.noreply.hosted.weblate.org>
2026-05-31 09:44:16 +00:00
naibaandcloudcode 401713bab5 ci(gosec): silence G101 false positives on PAT context keys and scope const
gosec G101 flags apiTokenCtxKey, apiTokenLastUsedCtxKey, and the
ScopeNotificationGroupWrite identifier as hardcoded credentials. They are
context-key names and a scope string, not secrets. Annotate them with
#nosec G101 (matching the existing JWTSecretEnvKey precedent) so the
gosec CI step passes without disabling the rule globally.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 08:16:55 +00:00
naibaandcloudcode f246668586 ci(gosec): silence G101 false positives on PAT context keys and scope const
gosec G101 flags apiTokenCtxKey, apiTokenLastUsedCtxKey, and the
ScopeNotificationGroupWrite identifier as hardcoded credentials. They are
context-key names and a scope string, not secrets. Annotate them with
#nosec G101 (matching the existing JWTSecretEnvKey precedent) so the
gosec CI step passes without disabling the rule globally.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 08:16:55 +00:00
naibaandcloudcode 18c044d959 fix(security): set real IP from peer in ConfigUsePeerIP gRPC mode
In ConfigUsePeerIP mode ctxWithRealIP validated connectingIp but never
assigned it to ip, so CtxKeyRealIP was left empty. model.CheckIP and
model.BlockIP short-circuit on an empty IP, which silently disabled the
gRPC WAF block table and brute-force token counters for every peer-IP
deployment — including the stream interceptors this path now backs.

Assign ip = connectingIp so the WAF and BlockIP observe the source. The
empty-header path is unchanged (it intentionally opts out of IP WAF).

Add rpc_test.go covering peer-IP IPv4/IPv6, the no-peer error, and the
no-header opt-out so the behaviours cannot be re-coupled.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 07:45:23 +00:00
naibaandcloudcode 440ef8e714 fix(security): set real IP from peer in ConfigUsePeerIP gRPC mode
In ConfigUsePeerIP mode ctxWithRealIP validated connectingIp but never
assigned it to ip, so CtxKeyRealIP was left empty. model.CheckIP and
model.BlockIP short-circuit on an empty IP, which silently disabled the
gRPC WAF block table and brute-force token counters for every peer-IP
deployment — including the stream interceptors this path now backs.

Assign ip = connectingIp so the WAF and BlockIP observe the source. The
empty-header path is unchanged (it intentionally opts out of IP WAF).

Add rpc_test.go covering peer-IP IPv4/IPv6, the no-peer error, and the
no-header opt-out so the behaviours cannot be re-coupled.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 07:45:23 +00:00
naibaandcloudcode b1d84c6b2f fix(security): harden gRPC stream WAF, MCP fs arg validation, and PAT server binding
- ServeRPC now applies real-IP + WAF interceptors to streaming RPCs
  (RequestTask, IOStream), not just unary calls; without them
  authHandler.check saw an empty real IP so brute-force BlockIP counters
  never keyed on a source and the WAF block table was bypassed at the
  stream entrypoint. Factored real-IP resolution into ctxWithRealIP shared
  by unary and stream paths.
- handleFsRead rejects negative offset/length; handleFsWrite validates
  if_match_sha256 is 64 hex chars before dispatch.
- createAPIToken always verifies each server_id exists (even for admins),
  so an admin cannot bind a PAT to a not-yet-created server id that a
  future server would auto-inherit.
- Add relay zero-length-chunk rejection regression test.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 05:51:32 +00:00
naibaandcloudcode 4f79fd6c2b fix(security): harden gRPC stream WAF, MCP fs arg validation, and PAT server binding
- ServeRPC now applies real-IP + WAF interceptors to streaming RPCs
  (RequestTask, IOStream), not just unary calls; without them
  authHandler.check saw an empty real IP so brute-force BlockIP counters
  never keyed on a source and the WAF block table was bypassed at the
  stream entrypoint. Factored real-IP resolution into ctxWithRealIP shared
  by unary and stream paths.
- handleFsRead rejects negative offset/length; handleFsWrite validates
  if_match_sha256 is 64 hex chars before dispatch.
- createAPIToken always verifies each server_id exists (even for admins),
  so an admin cannot bind a PAT to a not-yet-created server id that a
  future server would auto-inherit.
- Add relay zero-length-chunk rejection regression test.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 05:51:32 +00:00
naibaandcloudcode 05e14981b5 fix(security): verify transfer-token HMAC and sign CSRF double-submit cookie
- consumeTransferToken now recomputes and constant-time compares the
  entry's HMAC-SHA256; previously the signature was minted but never
  checked, so the documented "防篡改" guarantee was hollow and security
  rested solely on the sync.Map key's randomness.
- CSRF switches from a raw-random double-submit cookie to a signed token
  (nonce.HMAC-SHA256 keyed by JWTSecretKey). The middleware now also
  validates the signature, defeating sibling-subdomain cookie tossing
  where a naive header==cookie pair would otherwise pass.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 05:48:50 +00:00
naibaandcloudcode 834ae25024 fix(security): verify transfer-token HMAC and sign CSRF double-submit cookie
- consumeTransferToken now recomputes and constant-time compares the
  entry's HMAC-SHA256; previously the signature was minted but never
  checked, so the documented "防篡改" guarantee was hollow and security
  rested solely on the sync.Map key's randomness.
- CSRF switches from a raw-random double-submit cookie to a signed token
  (nonce.HMAC-SHA256 keyed by JWTSecretKey). The middleware now also
  validates the signature, defeating sibling-subdomain cookie tossing
  where a naive header==cookie pair would otherwise pass.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 05:48:50 +00:00
naibaandcloudcode d4837dfc7a fix(security): reserve dashboard hosts from NAT and re-check DDNS profile ownership at worker time
GHSA-x6fg-52vr-hj4w: NAT is a commonHandler any authenticated member can
create, and newHTTPandGRPCMux matches r.Host against NAT before dispatching
the dashboard/gRPC handlers, so a member could register the dashboard's own
host as a NAT Domain and hijack global routing. Add IsReservedDashboardHost
(InstallHost/ListenHost plus an operator-declared ReservedHosts list for
reverse-proxy deployments), reject reserved hosts on create/update, and drop
pre-planted records when building the startup cache.

GHSA-39g2-8x68-pmx8: bind-time CheckPermission let a member pre-bind a DDNS
profile ID that the victim would only create later. GetDDNSProvidersFromProfiles
now re-validates ownership by server owner UID and skips foreign-owned
profiles (UserID==0 is treated as a migration artifact, not an admin grant).

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 02:04:35 +00:00
naibaandcloudcode f18232eafa fix(security): reserve dashboard hosts from NAT and re-check DDNS profile ownership at worker time
GHSA-x6fg-52vr-hj4w: NAT is a commonHandler any authenticated member can
create, and newHTTPandGRPCMux matches r.Host against NAT before dispatching
the dashboard/gRPC handlers, so a member could register the dashboard's own
host as a NAT Domain and hijack global routing. Add IsReservedDashboardHost
(InstallHost/ListenHost plus an operator-declared ReservedHosts list for
reverse-proxy deployments), reject reserved hosts on create/update, and drop
pre-planted records when building the startup cache.

GHSA-39g2-8x68-pmx8: bind-time CheckPermission let a member pre-bind a DDNS
profile ID that the victim would only create later. GetDDNSProvidersFromProfiles
now re-validates ownership by server owner UID and skips foreign-owned
profiles (UserID==0 is treated as a migration artifact, not an admin grant).

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 02:04:35 +00:00
naibaandcloudcode e8dabf5bc6 feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.

Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.

Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.

Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-30 15:56:44 +00:00
naibaandcloudcode ab25662ddd feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.

Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.

Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.

Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-30 15:56:44 +00:00
029695344c chore(frontend-templates): bump admin-frontend to v2.0.9
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 07:12:31 +00:00
58c1ea7b0b chore(frontend-templates): bump admin-frontend to v2.0.9
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 07:12:31 +00:00
57703c4c3e ci(gosec): mark jwt secret env var name as non-secret
Gosec G101 treats the environment variable name NZ_JWTSECRETKEY as a hardcoded secret value. Keep the env-first JWT secret behavior unchanged and annotate the constant so CI only suppresses this false positive.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 05:27:46 +00:00
35fc24e2b2 ci(gosec): mark jwt secret env var name as non-secret
Gosec G101 treats the environment variable name NZ_JWTSECRETKEY as a hardcoded secret value. Keep the env-first JWT secret behavior unchanged and annotate the constant so CI only suppresses this false positive.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 05:27:46 +00:00
naibaandcloudcode c94639e199 fix(server-group): hide guest-empty groups from guest listing
listServerGroup returned every group, including ones whose only
servers are HideForGuest=true (or that have zero members), to
unauthenticated callers. The group name itself is then leaked to
guests even though every server it references is hidden from them.

Guest visitors now only see groups that contain at least one guest-
visible server. Authenticated members and admins keep full visibility
(including their own empty groups) so management UI still works.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 04:07:50 +00:00
naibaandcloudcode 76a5e2b261 fix(server-group): hide guest-empty groups from guest listing
listServerGroup returned every group, including ones whose only
servers are HideForGuest=true (or that have zero members), to
unauthenticated callers. The group name itself is then leaked to
guests even though every server it references is hidden from them.

Guest visitors now only see groups that contain at least one guest-
visible server. Authenticated members and admins keep full visibility
(including their own empty groups) so management UI still works.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 04:07:50 +00:00
naibaandcloudcode a4f4cb1f34 fix(fm): switch create FM session to POST to defeat CSRF
GET /api/v1/file?id=<server> created an FM stream on the agent stream
and committed real state change (TaskTypeFM dispatched). With JWT
cookie SameSite=Lax a victim's browser would still send the cookie on
a top-level cross-site GET, so an attacker could trick a logged-in
user into opening an FM session on any of their own servers, consuming
resources and triggering the agent's FM machinery without consent.

Mirror the GHSA-8qhj-4f8c-j8qg fix: move the route to POST. SameSite=
Lax cookies are not sent on cross-site POST. Frontend (admin-frontend)
adjusted in a follow-up commit.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 04:07:49 +00:00
naibaandcloudcode 00fde59d4d fix(fm): switch create FM session to POST to defeat CSRF
GET /api/v1/file?id=<server> created an FM stream on the agent stream
and committed real state change (TaskTypeFM dispatched). With JWT
cookie SameSite=Lax a victim's browser would still send the cookie on
a top-level cross-site GET, so an attacker could trick a logged-in
user into opening an FM session on any of their own servers, consuming
resources and triggering the agent's FM machinery without consent.

Mirror the GHSA-8qhj-4f8c-j8qg fix: move the route to POST. SameSite=
Lax cookies are not sent on cross-site POST. Frontend (admin-frontend)
adjusted in a follow-up commit.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 04:07:49 +00:00
naibaandcloudcode a8f014838c fix(service): hide EnableShowInService=false services from sideband endpoints
GHSA-vrmh-5mmx-hjwx: GET /api/v1/server/:id/service and
GET /api/v1/service/:id/history both iterate the raw service list and
emit ServiceName / timing for any service that happens to monitor the
queried server, ignoring the owner's EnableShowInService=false flag.
Both routes are on optionalAuth, so unauthenticated visitors could
enumerate hidden services by name and timing.

Introduce userCanViewService(c, service):
- EnableShowInService=true     -> always visible
- admin                        -> always visible
- authenticated owner          -> visible (HasPermission)
- everyone else                -> hidden

getServiceHistory rejects unknown-or-invisible service with the same
'service not found' message so the endpoint cannot be used as an
oracle. listServerServices pre-filters the sorted service list.

Owners and admins keep their existing visibility into their own hidden
services.

Tests:
- TestUserCanViewServiceVisibleServiceIsPublic
- TestUserCanViewServiceHiddenServiceRejectsGuest
- TestUserCanViewServiceHiddenServiceRejectsForeignMember
- TestUserCanViewServiceHiddenServiceAllowsOwner
- TestUserCanViewServiceHiddenServiceAllowsAdmin

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:59:59 +00:00
naibaandcloudcode 94b7deef24 fix(service): hide EnableShowInService=false services from sideband endpoints
GHSA-vrmh-5mmx-hjwx: GET /api/v1/server/:id/service and
GET /api/v1/service/:id/history both iterate the raw service list and
emit ServiceName / timing for any service that happens to monitor the
queried server, ignoring the owner's EnableShowInService=false flag.
Both routes are on optionalAuth, so unauthenticated visitors could
enumerate hidden services by name and timing.

Introduce userCanViewService(c, service):
- EnableShowInService=true     -> always visible
- admin                        -> always visible
- authenticated owner          -> visible (HasPermission)
- everyone else                -> hidden

getServiceHistory rejects unknown-or-invisible service with the same
'service not found' message so the endpoint cannot be used as an
oracle. listServerServices pre-filters the sorted service list.

Owners and admins keep their existing visibility into their own hidden
services.

Tests:
- TestUserCanViewServiceVisibleServiceIsPublic
- TestUserCanViewServiceHiddenServiceRejectsGuest
- TestUserCanViewServiceHiddenServiceRejectsForeignMember
- TestUserCanViewServiceHiddenServiceAllowsOwner
- TestUserCanViewServiceHiddenServiceAllowsAdmin

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:59:59 +00:00
naibaandcloudcode 9c0fa84c95 fix(cron): switch manual trigger to POST to defeat CSRF
GHSA-8qhj-4f8c-j8qg: GET /api/v1/cron/:id/manual changed shared state
on the agent stream, and the JWT cookie is SameSite=Lax so a victim's
browser would send the cookie on a top-level cross-site GET. An
attacker could trick a logged-in user into firing any of their own
cron commands.

Switch the route to POST: SameSite=Lax cookies are not sent on cross-
site POST, closing the CSRF window without introducing a new token.

Tests:
- TestCronManualTriggerRejectsCrossSiteGET locks in that GET no longer
  resolves.
- TestCronManualTriggerAcceptsSameSitePOST locks in the legitimate POST
  still works.

Frontend (admin-frontend) updated in a follow-up commit.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:59:50 +00:00
naibaandcloudcode 9929e04643 fix(cron): switch manual trigger to POST to defeat CSRF
GHSA-8qhj-4f8c-j8qg: GET /api/v1/cron/:id/manual changed shared state
on the agent stream, and the JWT cookie is SameSite=Lax so a victim's
browser would send the cookie on a top-level cross-site GET. An
attacker could trick a logged-in user into firing any of their own
cron commands.

Switch the route to POST: SameSite=Lax cookies are not sent on cross-
site POST, closing the CSRF window without introducing a new token.

Tests:
- TestCronManualTriggerRejectsCrossSiteGET locks in that GET no longer
  resolves.
- TestCronManualTriggerAcceptsSameSitePOST locks in the legitimate POST
  still works.

Frontend (admin-frontend) updated in a follow-up commit.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:59:50 +00:00
naibaandcloudcode e05700c2f0 feat(jwt): server-side session table with keyId + obfuscated uid claims
Replace the {user_id, ip} claim pair with {keyId, uid}:
- keyId is a 32-byte random id that points to a row in the new
  jwt_sessions table holding the real user id, bound IP, UA hash,
  TokenVersion and expiry.
- uid is the user id encoded through pkg/idcodec; mismatch between
  claim uid and session.UserID trips WAF block on the caller IP.
- identityHandler now rejects unknown/revoked/expired sessions, IP
  drift and stale TokenVersion. Refresh updates session.ExpiresAt.

User.TokenVersion bumps on password change and revokes outstanding
sessions, so a leaked JWT secret alone is no longer enough to forge
a token. JWTSession rows are GC'd every 10 minutes (expired + grace
or revoked >24h). OAuth2 callback shares the same issue path.

Includes regression tests for happy path, mismatched claim uid,
revoked session, TokenVersion bump, IP drift and unknown keyId.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:51:05 +00:00
naibaandcloudcode 7b54a2d5ea feat(jwt): server-side session table with keyId + obfuscated uid claims
Replace the {user_id, ip} claim pair with {keyId, uid}:
- keyId is a 32-byte random id that points to a row in the new
  jwt_sessions table holding the real user id, bound IP, UA hash,
  TokenVersion and expiry.
- uid is the user id encoded through pkg/idcodec; mismatch between
  claim uid and session.UserID trips WAF block on the caller IP.
- identityHandler now rejects unknown/revoked/expired sessions, IP
  drift and stale TokenVersion. Refresh updates session.ExpiresAt.

User.TokenVersion bumps on password change and revokes outstanding
sessions, so a leaked JWT secret alone is no longer enough to forge
a token. JWTSession rows are GC'd every 10 minutes (expired + grace
or revoked >24h). OAuth2 callback shares the same issue path.

Includes regression tests for happy path, mismatched claim uid,
revoked session, TokenVersion bump, IP drift and unknown keyId.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:51:05 +00:00
naibaandcloudcode d6f381d078 feat(idcodec): add hashid obfuscation derived from jwt secret
New pkg/idcodec wraps sqids with an alphabet derived from the JWT
secret via HKDF-SHA256 (info="nezha/idcodec/alphabet/v1"). Rotating
NZ_JWTSECRETKEY automatically reshuffles the alphabet, which doubles
as a kill switch for outstanding hashids without touching the encoder
itself.

The base alphabet drops visually-confusable characters (0/O/o/I/l/1)
and MinLength=8 hides small integer ids. Decode round-trips through
Encode to reject inputs that decode by accident under the same
alphabet.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:50:36 +00:00
naibaandcloudcode 304afd9e09 feat(idcodec): add hashid obfuscation derived from jwt secret
New pkg/idcodec wraps sqids with an alphabet derived from the JWT
secret via HKDF-SHA256 (info="nezha/idcodec/alphabet/v1"). Rotating
NZ_JWTSECRETKEY automatically reshuffles the alphabet, which doubles
as a kill switch for outstanding hashids without touching the encoder
itself.

The base alphabet drops visually-confusable characters (0/O/o/I/l/1)
and MinLength=8 hides small integer ids. Decode round-trips through
Encode to reject inputs that decode by accident under the same
alphabet.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:50:36 +00:00
naibaandcloudcode 95c3a775d5 feat(config): make jwt_secret_key env-first and never persist when injected
NZ_JWTSECRETKEY takes priority over config.yaml, env-injected keys are
not persisted and skip the version-driven rotation. When the secret is
auto-generated as a last-resort fallback, write it via a single-field
YAML patch so we never round-trip the in-memory key back to disk.

The JWTSecretKey field is marked json:"-" yaml:"-" so Save() can no
longer leak it. Update ReadEnvFile test to lock in env-over-yaml
precedence.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:50:26 +00:00
naibaandcloudcode 6811f730e9 feat(config): make jwt_secret_key env-first and never persist when injected
NZ_JWTSECRETKEY takes priority over config.yaml, env-injected keys are
not persisted and skip the version-driven rotation. When the secret is
auto-generated as a last-resort fallback, write it via a single-field
YAML patch so we never round-trip the in-memory key back to disk.

The JWTSecretKey field is marked json:"-" yaml:"-" so Save() can no
longer leak it. Update ReadEnvFile test to lock in env-over-yaml
precedence.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-26 03:50:26 +00:00
naiba d202ecf8db chore(frontend-templates): bump admin-frontend to v2.0.8 2026-05-25 11:42:39 +00:00
naiba 1e0f479bb5 chore(frontend-templates): bump admin-frontend to v2.0.8 2026-05-25 11:42:39 +00:00
naiba b83d93fdc9 fix(server-transfer): break loader sort ties by transferID
NewServerTransferClass merged Verified and acked-rollback rows and sorted
them by AckedAt only. On Windows time.Now() granularity is ~15.6ms, so
MarkVerified and an immediately-following MarkRevertDelivered routinely
share a timestamp. The stable sort then left the Verified candidate
ahead of the rollback that is actually on disk and the agent was locked
out on the next restart. Add transferID as a deterministic tiebreaker so
the later rotation always wins.
2026-05-25 10:36:43 +00:00
naiba 3619e5551a fix(server-transfer): break loader sort ties by transferID
NewServerTransferClass merged Verified and acked-rollback rows and sorted
them by AckedAt only. On Windows time.Now() granularity is ~15.6ms, so
MarkVerified and an immediately-following MarkRevertDelivered routinely
share a timestamp. The stable sort then left the Verified candidate
ahead of the rollback that is actually on disk and the agent was locked
out on the next restart. Add transferID as a deterministic tiebreaker so
the later rotation always wins.
2026-05-25 10:36:43 +00:00
naiba 6b88cdb012 feat: server transfer rotation 2026-05-25 10:17:34 +00:00
naiba e816e94fca feat: server transfer rotation 2026-05-25 10:17:34 +00:00
github-actions[bot] 37b6db806f update contributors[no ci] 2026-05-22 23:55:19 +08:00
github-actions[bot] 636f4a9716 update contributors[no ci] 2026-05-22 23:55:19 +08:00
naiba 79a6dd76f8 Update vulnerability reporting contact method 2026-05-22 22:58:18 +08:00
naiba 6612d151cd Update vulnerability reporting contact method 2026-05-22 22:58:18 +08:00
naibaandnaiba/CloudCode 8d21062c3b fix(rpc): authorize agent task results
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-21 02:02:02 +00:00
naibaandnaiba/CloudCode 02129f16fb fix(rpc): authorize agent task results
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-21 02:02:02 +00:00
naibaandnaiba/CloudCode 0f7af0bcb2 fix(rpc): bind io streams to target agents
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-21 02:01:54 +00:00
naibaandnaiba/CloudCode 8add559ec8 fix(rpc): bind io streams to target agents
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-21 02:01:54 +00:00
naibaandnaiba/CloudCode a5e2b0c36d ci: free gitee release storage before uploads
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 05:06:38 +00:00
naibaandnaiba/CloudCode 79c06d0f95 ci: free gitee release storage before uploads
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 05:06:38 +00:00
naibaandnaiba/CloudCode d8f077ffa2 ci: relax gitee release upload timeout
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 03:59:48 +00:00
naibaandnaiba/CloudCode fd08b35d81 ci: relax gitee release upload timeout
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 03:59:48 +00:00
naibaandnaiba/CloudCode d61f953c81 ci: make gitee release sync retryable
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 03:17:33 +00:00
naibaandnaiba/CloudCode 8d8fcb1275 ci: make gitee release sync retryable
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 03:17:33 +00:00
naibaandnaiba/CloudCode 1efe2bf053 fix(rpc): allow global agent secret across server owners
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 01:58:53 +00:00
naibaandnaiba/CloudCode fa31ba402e fix(rpc): allow global agent secret across server owners
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 01:58:53 +00:00
naibaandnaiba/CloudCode 9d67134148 fix(controller): filter service transfer stats by viewer
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 00:35:02 +00:00
naibaandnaiba/CloudCode 43624792a3 fix(controller): filter service transfer stats by viewer
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-19 00:35:02 +00:00
naibaandnaiba/CloudCode fb120074ac chore(security): pin JWT algorithm + SameSite and harden OAuth2 state cookie
- Set GinJWTMiddleware.SigningAlgorithm to "HS256" explicitly so a future
  library default change (or an alg:none confusion attempt) cannot weaken
  token validation. This matches the current gin-jwt default, so behaviour
  is unchanged.
- Set CookieSameSite to Lax: same as the modern-browser default, but
  pinned so server-side intent is clear and CSRF on cross-site POST is
  blocked while top-level GET (OAuth callback) still works.
- Move the nz-o2s OAuth2 state cookie into writeOauth2StateCookie and
  set HttpOnly=true. The frontend does not read this cookie, so HttpOnly
  is strictly an XSS-hardening win with no behaviour change.

JWT Cookie HttpOnly/Secure are intentionally left default for now: the
frontend reads \`!!document.cookie\` to display login state and many
deployments terminate TLS at an upstream proxy — flipping those would
require a coordinated frontend change.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:17:59 +00:00
naibaandnaiba/CloudCode 59ccc12e58 chore(security): pin JWT algorithm + SameSite and harden OAuth2 state cookie
- Set GinJWTMiddleware.SigningAlgorithm to "HS256" explicitly so a future
  library default change (or an alg:none confusion attempt) cannot weaken
  token validation. This matches the current gin-jwt default, so behaviour
  is unchanged.
- Set CookieSameSite to Lax: same as the modern-browser default, but
  pinned so server-side intent is clear and CSRF on cross-site POST is
  blocked while top-level GET (OAuth callback) still works.
- Move the nz-o2s OAuth2 state cookie into writeOauth2StateCookie and
  set HttpOnly=true. The frontend does not read this cookie, so HttpOnly
  is strictly an XSS-hardening win with no behaviour change.

JWT Cookie HttpOnly/Secure are intentionally left default for now: the
frontend reads \`!!document.cookie\` to display login state and many
deployments terminate TLS at an upstream proxy — flipping those would
require a coordinated frontend change.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:17:59 +00:00
naibaandnaiba/CloudCode 7c493e8f0b fix(rpc): verify gRPC client UUID belongs to the agent secret owner
Auth.Check resolved client_uuid → server.ID via UUIDToID without
checking that the resolved server's UserID matched the user that the
secret was bound to. An agent presenting one user's secret could
target a different user's server UUID and impersonate it — poisoning
monitoring state, triggering alerts, or quietly receiving tasks the
real owner expected.

Add authorizeAgentForUUID: if the UUID is unknown we still allow new
registration bound to the secret owner; if the UUID is known but
points to someone else's server we reject. The rejection message
"client UUID does not belong to the agent secret owner" also helps
operators trace which user's secret has leaked.

Note: this changes runtime behaviour after batch-move/server — the
new owner must reconfigure agents with their own secret. That is the
correct contract; the previous behaviour was a cross-user reporting
bug.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:17:37 +00:00
naibaandnaiba/CloudCode ee01a174fb fix(rpc): verify gRPC client UUID belongs to the agent secret owner
Auth.Check resolved client_uuid → server.ID via UUIDToID without
checking that the resolved server's UserID matched the user that the
secret was bound to. An agent presenting one user's secret could
target a different user's server UUID and impersonate it — poisoning
monitoring state, triggering alerts, or quietly receiving tasks the
real owner expected.

Add authorizeAgentForUUID: if the UUID is unknown we still allow new
registration bound to the secret owner; if the UUID is known but
points to someone else's server we reject. The rejection message
"client UUID does not belong to the agent secret owner" also helps
operators trace which user's secret has leaked.

Note: this changes runtime behaviour after batch-move/server — the
new owner must reconfigure agents with their own secret. That is the
correct contract; the previous behaviour was a cross-user reporting
bug.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:17:37 +00:00
naibaandnaiba/CloudCode 280e34977f fix(rpc): enforce magic ff05ff05 on IOStream init
The inline magic check expressed the *invalid* form as
\`byte0 != 0xff && byte1 != 0x05 && byte2 != 0xff && byte3 == 0x05\`,
relying on && to detect a four-byte mismatch. Because && short-circuits,
any payload whose byte0 happened to be 0xff was treated as a valid magic
even if the remaining bytes did not match — almost every random payload
slipped through and only the stream-UUID layer above stood between a
caller with a valid agent secret and a live IOStream session.

Extract the check into isValidIOStreamMagic stated positively (all four
bytes must match) so short-circuit reasoning cannot reintroduce the bug.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:17:18 +00:00
naibaandnaiba/CloudCode 26cf9b3fa6 fix(rpc): enforce magic ff05ff05 on IOStream init
The inline magic check expressed the *invalid* form as
\`byte0 != 0xff && byte1 != 0x05 && byte2 != 0xff && byte3 == 0x05\`,
relying on && to detect a four-byte mismatch. Because && short-circuits,
any payload whose byte0 happened to be 0xff was treated as a valid magic
even if the remaining bytes did not match — almost every random payload
slipped through and only the stream-UUID layer above stood between a
caller with a valid agent secret and a live IOStream session.

Extract the check into isValidIOStreamMagic stated positively (all four
bytes must match) so short-circuit reasoning cannot reintroduce the bug.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:17:18 +00:00
naibaandnaiba/CloudCode 097d4f7382 fix(controller): hide foreign server IDs from forceUpdateServer response
The previous loop only checked ownership inside the
\`server != nil && server.TaskStream != nil\` branch. A foreign online
server returned permission denied for the whole batch while foreign
offline / unknown IDs silently went into the Offline bucket — the
response-shape delta let a RoleMember enumerate other users' online
server IDs by submitting them in batches.

Drop both foreign and unknown IDs silently into the Offline bucket
(without dispatching the upgrade task) so the response is indistinguishable
across those three states.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:16:59 +00:00
naibaandnaiba/CloudCode 710a2c731c fix(controller): hide foreign server IDs from forceUpdateServer response
The previous loop only checked ownership inside the
\`server != nil && server.TaskStream != nil\` branch. A foreign online
server returned permission denied for the whole batch while foreign
offline / unknown IDs silently went into the Offline bucket — the
response-shape delta let a RoleMember enumerate other users' online
server IDs by submitting them in batches.

Drop both foreign and unknown IDs silently into the Offline bucket
(without dispatching the upgrade task) so the response is indistinguishable
across those three states.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:16:59 +00:00
naibaandnaiba/CloudCode 36297699f5 fix(rpc): bind io_stream sessions to creator to prevent terminal/fm hijack
createTerminal and createFM correctly check server ownership before
issuing a stream UUID, but terminalStream and fmStream only verified
that the UUID existed. Any authenticated user holding a valid stream
UUID could attach to it, gaining the original creator's live shell or
file-manager session — and the UUID is exposed via URL path (referer
leaks, access logs, browser history, frontend error reporters).

Bind the creator user ID into ioStreamContext at CreateStream time,
expose StreamOwnership and IsStreamAuthorizedForUser, and check
ownership in terminalStream/fmStream before the WebSocket upgrade so a
rejected attempt does not tear down the legitimate stream via defer.

NAT streams are also routed through CreateStream(_, 0); they are not
reachable from /ws/terminal or /ws/file so a sentinel user ID is fine.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:16:40 +00:00
naibaandnaiba/CloudCode 6661d6a7fc fix(rpc): bind io_stream sessions to creator to prevent terminal/fm hijack
createTerminal and createFM correctly check server ownership before
issuing a stream UUID, but terminalStream and fmStream only verified
that the UUID existed. Any authenticated user holding a valid stream
UUID could attach to it, gaining the original creator's live shell or
file-manager session — and the UUID is exposed via URL path (referer
leaks, access logs, browser history, frontend error reporters).

Bind the creator user ID into ioStreamContext at CreateStream time,
expose StreamOwnership and IsStreamAuthorizedForUser, and check
ownership in terminalStream/fmStream before the WebSocket upgrade so a
rejected attempt does not tear down the legitimate stream via defer.

NAT streams are also routed through CreateStream(_, 0); they are not
reachable from /ws/terminal or /ws/file so a sentinel user ID is fine.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:16:40 +00:00
naibaandnaiba/CloudCode ea7ad67f03 fix(ddns): apply SSRF defense to webhook provider
GHSA-6x26-5727-rrm9: a low-privilege member could point a DDNS webhook
at internal or loopback hosts and the dashboard would dial them with the
unrestricted utils.HttpClient.

Extract the notification SSRF defenses (CIDR blocklist, IP-pin DialContext,
SNI preservation, redirect rejection) into reusable helpers in pkg/utils
(NewRestrictedHTTPClient / ResolveAllowedHTTPURL / buildRestrictedHTTPClient)
and route the DDNS webhook through the same path. Replace the notification
inline implementation with a thin wrapper to keep behaviour identical.

Side improvements collected by the refactor:
- prepareRequest now resolves DNS once and returns the paired client, so
  the dialer's pinned IP and the validated URL stay in sync (no more
  double resolution between prepareRequest and SetRecords).
- response body is drained and closed.
- HttpClient / HttpClientSkipTlsVerify are explicitly tagged unsafe for
  attacker-controlled URLs.

Tests cover: hermetic SNI preservation, redirect rejection, dial pin to
the vetted IP, the full blocked-CIDR list at the webhook entry point,
and the verifyTLS↔skipVerifyTLS inversion in the notification wrapper.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:14:27 +00:00
naibaandnaiba/CloudCode e7c2e453c0 fix(ddns): apply SSRF defense to webhook provider
GHSA-6x26-5727-rrm9: a low-privilege member could point a DDNS webhook
at internal or loopback hosts and the dashboard would dial them with the
unrestricted utils.HttpClient.

Extract the notification SSRF defenses (CIDR blocklist, IP-pin DialContext,
SNI preservation, redirect rejection) into reusable helpers in pkg/utils
(NewRestrictedHTTPClient / ResolveAllowedHTTPURL / buildRestrictedHTTPClient)
and route the DDNS webhook through the same path. Replace the notification
inline implementation with a thin wrapper to keep behaviour identical.

Side improvements collected by the refactor:
- prepareRequest now resolves DNS once and returns the paired client, so
  the dialer's pinned IP and the validated URL stay in sync (no more
  double resolution between prepareRequest and SetRecords).
- response body is drained and closed.
- HttpClient / HttpClientSkipTlsVerify are explicitly tagged unsafe for
  attacker-controlled URLs.

Tests cover: hermetic SNI preservation, redirect rejection, dial pin to
the vetted IP, the full blocked-CIDR list at the webhook entry point,
and the verifyTLS↔skipVerifyTLS inversion in the notification wrapper.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:14:27 +00:00
naibaandnaiba/CloudCode b2294f11dd fix(ws): scope server stream visibility to viewer ownership
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 03:41:28 +00:00
naibaandnaiba/CloudCode 05e5da2535 fix(ws): scope server stream visibility to viewer ownership
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 03:41:28 +00:00
naibaandnaiba/CloudCode 2b6a1430ff chore(deps): upgrade go modules to latest
Bumps direct deps: VictoriaMetrics v1.134.0 -> v1.143.0,
gin-contrib/pprof v1.5.3 -> v1.5.4, knadh/koanf/v2 v2.3.3 -> v2.3.4,
libdns/he v1.2.1 -> v1.2.2, tidwall/gjson v1.18.0 -> v1.19.0,
golang.org/x/crypto v0.49.0 -> v0.51.0,
golang.org/x/net v0.52.0 -> v0.54.0,
google.golang.org/grpc v1.79.3 -> v1.81.1, plus transitive updates.

VictoriaMetrics v1.143.0 requires go >= 1.26.3, so the go directive
is bumped from 1.26 to 1.26.3.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 02:40:09 +00:00
naibaandnaiba/CloudCode e1244ca372 chore(deps): upgrade go modules to latest
Bumps direct deps: VictoriaMetrics v1.134.0 -> v1.143.0,
gin-contrib/pprof v1.5.3 -> v1.5.4, knadh/koanf/v2 v2.3.3 -> v2.3.4,
libdns/he v1.2.1 -> v1.2.2, tidwall/gjson v1.18.0 -> v1.19.0,
golang.org/x/crypto v0.49.0 -> v0.51.0,
golang.org/x/net v0.52.0 -> v0.54.0,
google.golang.org/grpc v1.79.3 -> v1.81.1, plus transitive updates.

VictoriaMetrics v1.143.0 requires go >= 1.26.3, so the go directive
is bumped from 1.26 to 1.26.3.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 02:40:09 +00:00
naibaandnaiba/CloudCode 854e2edf17 chore: bump admin frontend to v2.0.7
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 9f21db2dc6 chore: bump admin frontend to v2.0.7
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 7061651587 test: cover multi-user permission boundaries
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode c0e2470a9d test: cover multi-user permission boundaries
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 2573ba7522 fix(controller): restrict waf and online-user listings to admins
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 530722293f fix(controller): restrict waf and online-user listings to admins
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 0ca7ee38c0 fix(controller): filter listing endpoints by ownership
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode acc2b63ce2 fix(controller): filter listing endpoints by ownership
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 2a65a7db80 fix(controller): enforce ownership on notification group, NAT server, and batch move
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode a5995ce045 fix(controller): enforce ownership on notification group, NAT server, and batch move
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 58f98db1a0 fix(controller): validate trigger task ownership
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode 423175a425 fix(controller): validate trigger task ownership
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode e38a0ef469 fix(cron): restrict task delivery by owner
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode d7526351cf fix(cron): restrict task delivery by owner
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode c4bea1ffd3 fix(notification): harden webhook request handling
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
naibaandnaiba/CloudCode d06d539d34 fix(notification): harden webhook request handling
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-17 10:24:19 +08:00
Bot f3dded3186 feat: separate expiry notification days and refactor TG bot interactive logic 2026-05-13 00:52:36 +08:00
Bot 3d00f09493 feat: implement self-hosted scripts and agent binary proxy with tar.gz conversion 2026-05-11 17:55:30 +08:00
uubulb 3e889dc3d9 fix(OnUserDelete): only delete current iterated user from db 2026-05-09 12:38:39 +08:00
uubulb 85b0dd2992 fix(OnUserDelete): only delete current iterated user from db 2026-05-09 12:38:39 +08:00
Bot 3319d7344f feat: rewrite notification system and telegram interactive bot 2026-04-28 00:04:29 +08:00
Bot c606fd99f6 chore: optimize and fix github actions (cleanup, CI fixes, security scanner tuning) 2026-04-26 22:25:00 +08:00
Bot b4a0641177 feat: use RDAP as primary domain info source for higher reliability 2026-04-16 23:18:42 +08:00
Bot a3e2b2edba fix: resolve import errors and clarify whois error messages 2026-04-16 23:16:30 +08:00
Bot 3d19be657d feat: automated renewal price sync via nazhumi API 2026-04-16 23:11:30 +08:00
Bot 5dfc049121 feat: implement automated WHOIS sync for domains 2026-04-16 23:08:50 +08:00
Bot 5493f5c32f feat: add native Telegram notification provider 2026-04-16 21:43:44 +08:00
Bot dc58e84946 fix: automatic theme switching and extended expiry notifications 2026-04-16 21:40:26 +08:00
Bot 3d849b4b43 feat: implement VPS/Domain expiry notifications and native SMTP support 2026-04-16 21:36:49 +08:00
Bot bc32f8fce4 feat: add support for custom branding and background settings in Config 2026-04-16 17:18:48 +08:00
Bot 23ece08076 Remove WebSocket SSH terminal API implementation 2026-04-16 16:21:24 +08:00
Buriburizaemon 2ad62df7b3 Merge upstream/master and resolve conflicts 2026-04-16 11:58:30 +08:00
naiba 027cb5f4b7 chore: upgrade frontend 2026-04-05 10:00:11 +08:00
naiba 50dc8e6603 chore: upgrade frontend 2026-04-05 10:00:11 +08:00
naibaandnaiba/CloudCode 241506e390 chore: bump Go to 1.26, update dependencies and frontend templates
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-03-20 11:23:03 +00:00
naibaandnaiba/CloudCode 9acffc11f6 chore: bump Go to 1.26, update dependencies and frontend templates
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-03-20 11:23:03 +00:00
naiba 5c07c3befb Merge pull request #1177 from nezhahq/dependabot/go_modules/google.golang.org/grpc-1.79.3
chore(deps): bump google.golang.org/grpc from 1.76.0 to 1.79.3
2026-03-20 18:39:37 +08:00
naiba 4e9513501f Merge pull request #1177 from nezhahq/dependabot/go_modules/google.golang.org/grpc-1.79.3
chore(deps): bump google.golang.org/grpc from 1.76.0 to 1.79.3
2026-03-20 18:39:37 +08:00
dependabot[bot] 87bf588a10 chore(deps): bump google.golang.org/grpc from 1.76.0 to 1.79.3
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.76.0 to 1.79.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.76.0...v1.79.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 02:27:20 +00:00
dependabot[bot] 91a636ce25 chore(deps): bump google.golang.org/grpc from 1.76.0 to 1.79.3
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.76.0 to 1.79.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.76.0...v1.79.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 02:27:20 +00:00
naibaandnaiba/CloudCode de9ab16c5a fix: 恢复被误删的 AuthCodeURL 调用,修复编译失败
上次提交误删了 url := o2conf.AuthCodeURL(...) 导致编译失败。
同时添加注释说明 Cookie Secure 属性动态设置的原因。

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-03-01 04:05:53 +00:00
naibaandnaiba/CloudCode a5d4537a8c fix: 恢复被误删的 AuthCodeURL 调用,修复编译失败
上次提交误删了 url := o2conf.AuthCodeURL(...) 导致编译失败。
同时添加注释说明 Cookie Secure 属性动态设置的原因。

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-03-01 04:05:53 +00:00
naiba 0f9bc2d3ee chore: upgrade frontend 2026-03-01 11:50:56 +08:00
naiba 589563e744 chore: upgrade frontend 2026-03-01 11:50:56 +08:00
naibaandnaiba/CloudCode 16c4a6c9f3 Fix: 设置Cookie Secure属性增强安全性
- 根据运行环境动态设置Secure属性
- 开发环境兼容HTTP,生产环境强制HTTPS
- 解决CodeQL扫描发现的安全漏洞

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-03-01 03:49:20 +00:00
naibaandnaiba/CloudCode d57d7b73b6 Fix: 设置Cookie Secure属性增强安全性
- 根据运行环境动态设置Secure属性
- 开发环境兼容HTTP,生产环境强制HTTPS
- 解决CodeQL扫描发现的安全漏洞

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-03-01 03:49:20 +00:00
github-actions[bot] 7907400dbd update contributors[no ci] 2026-03-01 11:33:11 +08:00
github-actions[bot] 69ac37d98c update contributors[no ci] 2026-03-01 11:33:11 +08:00
naibaandnaiba/CloudCode 2a04897140 fix: upgrade CodeQL Action to v3 and generate swagger docs before build
- Upgrade github/codeql-action from v2 to v3 (v1/v2 deprecated)
- Upgrade actions/setup-go from v4 to v5
- Add swag init step before autobuild to generate cmd/dashboard/docs
  package, fixing "could not import: invalid package name" warning

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-03-01 03:32:49 +00:00
naibaandnaiba/CloudCode be8ff11cae fix: upgrade CodeQL Action to v3 and generate swagger docs before build
- Upgrade github/codeql-action from v2 to v3 (v1/v2 deprecated)
- Upgrade actions/setup-go from v4 to v5
- Add swag init step before autobuild to generate cmd/dashboard/docs
  package, fixing "could not import: invalid package name" warning

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-03-01 03:32:49 +00:00
github-actions[bot] 6caaea1651 update contributors[no ci] 2026-02-28 23:24:16 +08:00
github-actions[bot] c48c63c8ad update contributors[no ci] 2026-02-28 23:24:16 +08:00
奶爸 f2bb324d67 Merge pull request #1172 from NikoCat233/fix/guest-server-group-filter
Filter hidden servers in guest server-group API
2026-02-28 23:23:56 +08:00
奶爸 59b8ea3ce7 Merge pull request #1172 from NikoCat233/fix/guest-server-group-filter
Filter hidden servers in guest server-group API
2026-02-28 23:23:56 +08:00
NikoCat233 a42ef6e8bb Filter hidden servers in guest server-group API 2026-02-27 00:53:31 +08:00
NikoCat233 1a0d915afa Filter hidden servers in guest server-group API 2026-02-27 00:53:31 +08:00
Weblate (bot)andnlimeres 407017b1f6 Added translation using Weblate (Galician) (#1168)
Co-authored-by: nlimeres <dynosaurioprogramador@gmail.com>
2026-02-19 14:35:24 +08:00
Weblate (bot)andnlimeres 04553ca17f Added translation using Weblate (Galician) (#1168)
Co-authored-by: nlimeres <dynosaurioprogramador@gmail.com>
2026-02-19 14:35:24 +08:00
naiba fa7afd475f chore: bump admin-frontend to v2.0.4, nezha-dash-v1 to v2.0.1 2026-02-19 06:34:02 +00:00
naiba bef07c400c chore: bump admin-frontend to v2.0.4, nezha-dash-v1 to v2.0.1 2026-02-19 06:34:02 +00:00
naiba 398e7f730b feat: trigger AtomGit release sync on release 2026-02-16 15:31:46 +08:00
naiba b6977b2d45 feat: trigger AtomGit release sync on release 2026-02-16 15:31:46 +08:00
naiba 4e747491a2 fix: use Authorization header instead of access_token in request body for AtomGit API 2026-02-16 13:42:26 +08:00
naiba 6d2a12b7a4 fix: use Authorization header instead of access_token in request body for AtomGit API 2026-02-16 13:42:26 +08:00
naiba 34842c5d99 feat: add AtomGit code and release sync workflows 2026-02-16 13:36:45 +08:00
naiba d9e05e594a feat: add AtomGit code and release sync workflows 2026-02-16 13:36:45 +08:00
naiba 3c080d67eb fix(tsdb): update test to match 30s downsample interval for 1d period 2026-02-16 02:23:53 +08:00
naiba f9e379bdcc fix(tsdb): update test to match 30s downsample interval for 1d period 2026-02-16 02:23:53 +08:00
naiba d5172047eb feat(tsdb): increase 1d downsample resolution from 5min to 30s 2026-02-16 02:18:57 +08:00
naiba 4c1584dd5c feat(tsdb): increase 1d downsample resolution from 5min to 30s 2026-02-16 02:18:57 +08:00
3d0c9b963c feat(v2.0.0): tsdb (#1162)
* feat: tsdb

* fix(ci): remove --parseGoList=false from swag init to fix dependency resolution

* fix(ci): fix swag init directory and temporary remove s390x support due to cgo issues

* fix(ci): fix swag init output directory to cmd/dashboard/docs

* fix(ci): set GOTOOLCHAIN=auto for gosec

* feat: add system storage maintenance for SQLite and TSDB

* shit

* feat: add s390x support and improve service monitoring

* ci: upgrade goreleaser-cross image to v1.25

* ci: add libzstd-dev:s390x for cross-compilation

* ci: build libzstd for s390x from source

* ci: add libzstd_linux_s390x.go for gozstd linking

* ci: use vendor mode for s390x gozstd build

* ci: clone zstd source for s390x build

* refactor(tsdb): rename MaxDiskUsageGB to MinFreeDiskSpaceGB and optimize queries

- Rename config to accurately reflect VictoriaMetrics behavior: minimum free disk space threshold
- Add QueryServiceHistoryByServerID for batch query optimization
- Fix hasStatus to avoid false status counting when only delay data exists
- Fix service aggregation boundary: use successCount*2 >= count
- Fix serviceID parsing with strconv.ParseUint error handling
- Add TagFiltersCacheSize for better query performance

* feat(api): add server metrics endpoint and simplify service history response

- Add /server/:id/metrics API for querying TSDB server metrics
- Simplify getServiceHistory by removing redundant data conversion
- Change AvgDelay type from float32 to float64
- Remove generated swagger docs (to be regenerated)
- Update TSDB query, writer and tests

* chore: 临时禁用不支持前端

* ci: cache zstd build for s390x to speed up CI

* fix(tsdb): fix race conditions, data correctness and optimize performance

- Fix TOCTOU race between IsClosed() and write/query by holding RLock
- Fix delay=0 excluded from stats by using hasDelay flag instead of value > 0
- Fix fmt.Sscanf -> strconv.ParseUint for server_id parsing with error logging
- Fix buffer unbounded growth by flushing inside lock when over maxSize
- Split makeMetricRow into makeServerMetricRow/makeServiceMetricRow
- Extract InitGlobalSettings() from Open() for VictoriaMetrics globals
- Remove redundant instance/GetInstance/SetInstance singleton
- Add error logging for silently skipped block decode errors
- Optimize WriteBatch* to build all rows in single write call
- Optimize downsample to use linear scan instead of map for sorted data
- Optimize query slice reuse across block iterations

* 服务添加DisplayIndex (#1166)

* 服务添加DisplayIndex

* 根据ai建议修改

---------

Co-authored-by: huYang <306061454@qq.com>

* fix(tsdb): restore SQLite fallback and monthly status reload on restart

- Restore ServiceHistory model and SQLite write fallback when TSDB is disabled
- Reload monthlyStatus (30-day) and serviceStatusToday from TSDB/SQLite on startup
- Add SQLite fallback query for /service/:id/history and /server/:id/service
- Remove breaking GET /service/:id endpoint, keep /service/:id/history only
- Add QueryServiceDailyStats to TSDB for per-day aggregation
- Add tests for monthly status and today stats loading from both TSDB and SQLite
- Migrate ServiceHistory table only when TSDB is disabled

* ci: exclude false-positive gosec rules G117, G703, G704

* feat(api): expose tsdb_enabled in setting response

* ci: restore G115 exclusion accidentally dropped in previous commit

* fix: update version numbers for OfficialAdmin and Official templates

* chore: upgrade frontend

* chore: upgrade frontend

---------

Co-authored-by: 胡说丷刂 <34758853+laosan-xx@users.noreply.github.com>
Co-authored-by: huYang <306061454@qq.com>
2026-02-15 13:13:33 +08:00
e61772e858 feat(v2.0.0): tsdb (#1162)
* feat: tsdb

* fix(ci): remove --parseGoList=false from swag init to fix dependency resolution

* fix(ci): fix swag init directory and temporary remove s390x support due to cgo issues

* fix(ci): fix swag init output directory to cmd/dashboard/docs

* fix(ci): set GOTOOLCHAIN=auto for gosec

* feat: add system storage maintenance for SQLite and TSDB

* shit

* feat: add s390x support and improve service monitoring

* ci: upgrade goreleaser-cross image to v1.25

* ci: add libzstd-dev:s390x for cross-compilation

* ci: build libzstd for s390x from source

* ci: add libzstd_linux_s390x.go for gozstd linking

* ci: use vendor mode for s390x gozstd build

* ci: clone zstd source for s390x build

* refactor(tsdb): rename MaxDiskUsageGB to MinFreeDiskSpaceGB and optimize queries

- Rename config to accurately reflect VictoriaMetrics behavior: minimum free disk space threshold
- Add QueryServiceHistoryByServerID for batch query optimization
- Fix hasStatus to avoid false status counting when only delay data exists
- Fix service aggregation boundary: use successCount*2 >= count
- Fix serviceID parsing with strconv.ParseUint error handling
- Add TagFiltersCacheSize for better query performance

* feat(api): add server metrics endpoint and simplify service history response

- Add /server/:id/metrics API for querying TSDB server metrics
- Simplify getServiceHistory by removing redundant data conversion
- Change AvgDelay type from float32 to float64
- Remove generated swagger docs (to be regenerated)
- Update TSDB query, writer and tests

* chore: 临时禁用不支持前端

* ci: cache zstd build for s390x to speed up CI

* fix(tsdb): fix race conditions, data correctness and optimize performance

- Fix TOCTOU race between IsClosed() and write/query by holding RLock
- Fix delay=0 excluded from stats by using hasDelay flag instead of value > 0
- Fix fmt.Sscanf -> strconv.ParseUint for server_id parsing with error logging
- Fix buffer unbounded growth by flushing inside lock when over maxSize
- Split makeMetricRow into makeServerMetricRow/makeServiceMetricRow
- Extract InitGlobalSettings() from Open() for VictoriaMetrics globals
- Remove redundant instance/GetInstance/SetInstance singleton
- Add error logging for silently skipped block decode errors
- Optimize WriteBatch* to build all rows in single write call
- Optimize downsample to use linear scan instead of map for sorted data
- Optimize query slice reuse across block iterations

* 服务添加DisplayIndex (#1166)

* 服务添加DisplayIndex

* 根据ai建议修改

---------

Co-authored-by: huYang <306061454@qq.com>

* fix(tsdb): restore SQLite fallback and monthly status reload on restart

- Restore ServiceHistory model and SQLite write fallback when TSDB is disabled
- Reload monthlyStatus (30-day) and serviceStatusToday from TSDB/SQLite on startup
- Add SQLite fallback query for /service/:id/history and /server/:id/service
- Remove breaking GET /service/:id endpoint, keep /service/:id/history only
- Add QueryServiceDailyStats to TSDB for per-day aggregation
- Add tests for monthly status and today stats loading from both TSDB and SQLite
- Migrate ServiceHistory table only when TSDB is disabled

* ci: exclude false-positive gosec rules G117, G703, G704

* feat(api): expose tsdb_enabled in setting response

* ci: restore G115 exclusion accidentally dropped in previous commit

* fix: update version numbers for OfficialAdmin and Official templates

* chore: upgrade frontend

* chore: upgrade frontend

---------

Co-authored-by: 胡说丷刂 <34758853+laosan-xx@users.noreply.github.com>
Co-authored-by: huYang <306061454@qq.com>
2026-02-15 13:13:33 +08:00
naiba ab1d9bddc0 chore: upgrade frontend 2026-01-10 20:23:28 +08:00
naiba 4c4758207d chore: upgrade frontend 2026-01-10 20:23:28 +08:00
naiba 8ee8fe9ba7 chore: upgrade frontend 2026-01-10 17:38:15 +08:00
naiba 439192b4c8 chore: upgrade frontend 2026-01-10 17:38:15 +08:00
UUBulb c22446b20c feat(notification): add option to convert metric units in request body (#1156)
* feat(notification): add option to convert metric units in request body

* ignore gosec

* rename fields
2026-01-10 17:29:10 +08:00
UUBulb 302d278644 feat(notification): add option to convert metric units in request body (#1156)
* feat(notification): add option to convert metric units in request body

* ignore gosec

* rename fields
2026-01-10 17:29:10 +08:00
github-actions[bot] be22c5fbbb update contributors[no ci] 2026-01-02 22:05:02 +08:00
github-actions[bot] 6067a41038 update contributors[no ci] 2026-01-02 22:05:02 +08:00
奶爸 25bdb0e2ae chore: 垃圾佬论坛下线 2026-01-02 22:04:47 +08:00
奶爸 7c6bc3d5c7 chore: 垃圾佬论坛下线 2026-01-02 22:04:47 +08:00
github-actions[bot] 6a5abd0dde update contributors[no ci] 2026-01-01 11:51:51 +08:00
github-actions[bot] 7b92f335f2 update contributors[no ci] 2026-01-01 11:51:51 +08:00
naiba a134f6c0da fix: immutable release 2026-01-01 11:51:25 +08:00
naiba 1eee2329eb fix: immutable release 2026-01-01 11:51:25 +08:00
naiba 905930dfdf chore: upgrade frontend 2026-01-01 10:56:52 +08:00
naiba 554db7cd64 chore: upgrade frontend 2026-01-01 10:56:52 +08:00
naiba d7b36fe263 chore: disable javascript scan 2026-01-01 10:35:15 +08:00
naiba 7f8ca5cda9 chore: disable javascript scan 2026-01-01 10:35:15 +08:00
e4d42a617f Added translation using Weblate (Ukrainian) (#1128)
Added translation using Weblate (Japanese)

Co-authored-by: Zilong Liu <2821624044@qq.com>
Co-authored-by: Руслан Пузич <visp80@gmail.com>
2026-01-01 10:33:59 +08:00
6b07370c1b Added translation using Weblate (Ukrainian) (#1128)
Added translation using Weblate (Japanese)

Co-authored-by: Zilong Liu <2821624044@qq.com>
Co-authored-by: Руслан Пузич <visp80@gmail.com>
2026-01-01 10:33:59 +08:00
dependabot[bot] a3105d364c chore(deps): bump golang.org/x/crypto from 0.37.0 to 0.45.0 (#1138)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.37.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 10:31:21 +08:00
dependabot[bot] 4e68c2f874 chore(deps): bump golang.org/x/crypto from 0.37.0 to 0.45.0 (#1138)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.37.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 10:31:21 +08:00
UUBulb dc59135e5c fix: add a default error message for waf page (#1145) 2025-12-04 21:52:12 +08:00
UUBulb 4372de483b fix: add a default error message for waf page (#1145) 2025-12-04 21:52:12 +08:00
github-actions[bot] 5c107dd263 update contributors[no ci] 2025-12-04 21:51:57 +08:00
github-actions[bot] ae4eb2d052 update contributors[no ci] 2025-12-04 21:51:57 +08:00
UUBulb e0133a015c fix: use server ids in db query (#1146) 2025-12-04 21:51:33 +08:00
UUBulb 16d5c6450f fix: use server ids in db query (#1146) 2025-12-04 21:51:33 +08:00
naiba 543d2642f3 improve: not block ip when ip changed 2025-10-10 09:53:15 +08:00
naiba 9d1ee6f543 improve: not block ip when ip changed 2025-10-10 09:53:15 +08:00
naiba fbfe28a54e chore: back comp 2025-10-09 22:01:35 +08:00
naiba 245103615f chore: back comp 2025-10-09 22:01:35 +08:00
naiba 3b8d2f3736 fix: oauth2 login 2025-10-09 22:00:08 +08:00
naiba cd69af3a0a fix: oauth2 login 2025-10-09 22:00:08 +08:00
naiba 3b2cefdd07 feat: binding ip with session
🛡️staying safe even your frontend was hacked
2025-10-09 21:36:59 +08:00
naiba 540c1cb1e7 feat: binding ip with session
🛡️staying safe even your frontend was hacked
2025-10-09 21:36:59 +08:00
naiba 09adc1310c chore: upgrade frontend 2025-10-09 18:32:30 +08:00
naiba 1db4fe4679 chore: upgrade frontend 2025-10-09 18:32:30 +08:00
naiba 48d50f64df chore: upgrade frontend 2025-10-09 09:50:55 +08:00
naiba a9fd269ea9 chore: upgrade frontend 2025-10-09 09:50:55 +08:00
naiba 603df316d4 chore: upgrade frontend 2025-10-09 09:38:01 +08:00
naiba e1e0a812a6 chore: upgrade frontend 2025-10-09 09:38:01 +08:00
github-actions[bot] 7927805c0e update contributors[no ci] 2025-10-03 09:33:27 +08:00
github-actions[bot] 2df709157e update contributors[no ci] 2025-10-03 09:33:27 +08:00
honeok 03db2fddb5 fix: embed tzdata to correct container timezone (#1126)
Signed-off-by: honeok <i@honeok.com>
2025-10-03 09:33:12 +08:00
honeok 32f52067c0 fix: embed tzdata to correct container timezone (#1126)
Signed-off-by: honeok <i@honeok.com>
2025-10-03 09:33:12 +08:00
naiba 5cfde9c2f4 💩 f*ck react
react 纯傻逼,js ts 就是纯傻逼,浪费人生
2025-10-02 17:19:26 +08:00
naiba f16f0892bc 💩 f*ck react
react 纯傻逼,js ts 就是纯傻逼,浪费人生
2025-10-02 17:19:26 +08:00
naiba efba3c369d Merge branch 'master' of github.com:nezhahq/nezha 2025-10-02 15:30:25 +08:00
naiba d49065454d Merge branch 'master' of github.com:nezhahq/nezha 2025-10-02 15:30:25 +08:00
naiba 298e178d3c feat: upgrade frontend 2025-10-02 15:30:21 +08:00
naiba fece19a940 feat: upgrade frontend 2025-10-02 15:30:21 +08:00
github-actions[bot] 74f9c70e61 update contributors[no ci] 2025-10-02 14:29:27 +08:00
github-actions[bot] e1a03c9172 update contributors[no ci] 2025-10-02 14:29:27 +08:00
naiba 87770cd91e feat: upgrade frontend 2025-10-02 14:28:57 +08:00
naiba 085ac7540f feat: upgrade frontend 2025-10-02 14:28:57 +08:00
UUBulb 010ae7ef51 fix: member-created services shouldn't be applied to admin resources (#1113) 2025-08-28 22:37:44 +08:00
naiba 7b3c3131cc chore: upgrade frontend 2025-08-23 21:14:02 +08:00
dependabot[bot] 4fdf9f4027 chore(deps): bump github.com/go-viper/mapstructure/v2 (#1111)
Bumps [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure) from 2.3.0 to 2.4.0.
- [Release notes](https://github.com/go-viper/mapstructure/releases)
- [Changelog](https://github.com/go-viper/mapstructure/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-viper/mapstructure/compare/v2.3.0...v2.4.0)

---
updated-dependencies:
- dependency-name: github.com/go-viper/mapstructure/v2
  dependency-version: 2.4.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 22:39:52 +08:00
github-actions[bot] e32083f8b9 update contributors[no ci] 2025-08-02 15:52:27 +08:00
e4144f2d6c Added translation using Weblate (French) (#1093)
Translated using Weblate (Indonesian)

Currently translated at 45.0% (27 of 60 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (60 of 60 strings)





Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/id/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/ta/
Translation: Nezha/Nezha Dashboard

Co-authored-by: Kxroqi :3 <loxocker@gmail.com>
Co-authored-by: TommyLPB <hcrochetcanard@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
2025-08-02 15:52:14 +08:00
dependabot[bot] ea8697c5ab chore(deps): bump github.com/go-viper/mapstructure/v2 (#1097)
Bumps [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure) from 2.2.1 to 2.3.0.
- [Release notes](https://github.com/go-viper/mapstructure/releases)
- [Changelog](https://github.com/go-viper/mapstructure/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-viper/mapstructure/compare/v2.2.1...v2.3.0)

---
updated-dependencies:
- dependency-name: github.com/go-viper/mapstructure/v2
  dependency-version: 2.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-02 15:52:03 +08:00
naiba fae01b3f09 chore: sync timeout 2025-06-22 13:51:27 +08:00
github-actions[bot] 1fb920650d update contributors[no ci] 2025-06-16 23:47:05 +08:00
naiba 233bbc23b6 feat: 批量转移服务器给其他用户 2025-06-16 23:46:36 +08:00
github-actions[bot] 4d285ea0a9 update contributors[no ci] 2025-06-08 00:49:48 +08:00
d0cf04a3d3 Translated using Weblate (Indonesian) (#1049)
Currently translated at 43.3% (26 of 60 strings)

Added translation using Weblate (Indonesian)

Translated using Weblate (Vietnamese)

Currently translated at 16.6% (10 of 60 strings)

Added translation using Weblate (Vietnamese)

Translated using Weblate (Spanish)

Currently translated at 100.0% (60 of 60 strings)





Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/es/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/id/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/vi/
Translation: Nezha/Nezha Dashboard

Co-authored-by: Andres Pava <hola@andrespava.com>
Co-authored-by: Kxroqi :3 <loxocker@gmail.com>
Co-authored-by: hzhoanglee <nganhh.bi12-313@st.usth.edu.vn>
2025-06-08 00:49:31 +08:00
naiba b296eb7a39 chore: upgrade frontend 2025-05-04 11:35:14 +08:00
UUBulb 04d7651f58 update dependencies (#1077)
* update dependencies

ddns: update libdns to 1.0.0

* rename var
2025-05-02 00:25:23 +08:00
UUBulb 5c27259439 chore: cleanup some code (#1069)
* chore

* modernize loop

* ddns: simpify Provider struct
2025-04-26 18:28:21 +08:00
naiba afe483a831 feat: upgrade frontend 2025-04-25 23:03:14 +08:00
github-actions[bot] a83f74f2f5 update contributors[no ci] 2025-04-25 22:57:12 +08:00
dependabot[bot] 9515127991 chore(deps): bump golang.org/x/net from 0.37.0 to 0.38.0 (#1063)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.37.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.38.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-25 22:56:56 +08:00
github-actions[bot] 321c41250e update contributors[no ci] 2025-04-13 18:42:50 +08:00
naiba 9110853928 feat: upgrade frontend 2025-04-13 18:42:24 +08:00
github-actions[bot] eda7ade41d update contributors[no ci] 2025-04-13 18:41:43 +08:00
TomyJan 42c128995b feat: separate real ip header of frontend/agent (#1057)
* test(deps): use self admin-frontend

* feat: separate real ip header of frontend/agent

* test(ci): test

* Revert "test(ci): test"

This reverts commit 1634c7e7d7bca2873c13955abb582ea88645844f.

* test(ci): test

* test(ci): test

* test(ci): test

* Revert "test(ci): test"

This reverts commit 8fce20a07e5a9503f665937337050d4373ca7e78.

* Revert "test(ci): test"

This reverts commit 3267cccbfb81776a711e8cb34e676536f0852d1c.

* Revert "test(ci): test"

This reverts commit 566660c0c6a9875864aa46537bcc6788769337eb.

* Revert "test(deps): use self admin-frontend"

This reverts commit 16a838b374ff040800b2c1c5f8e5ede577645669.
2025-04-13 18:41:28 +08:00
naiba 0a36393258 feat: upgrade frontend 2025-04-13 12:28:28 +08:00
github-actions[bot] 310aab627a update contributors[no ci] 2025-04-13 12:26:18 +08:00
UUBulb 56769c9451 i18n: replace gettext implementation (#1056) 2025-04-13 12:26:03 +08:00
naiba b72536b74b feat: upgrade frontend 2025-03-31 19:53:19 +08:00
naiba e74706fd31 refactor: clean code 2025-03-31 19:52:23 +08:00
UUBulb b3377127a4 update ddns on server update (#1050) 2025-03-31 19:41:04 +08:00
naiba 0c22af4f83 Update README.md (#1051) 2025-03-30 18:05:12 +08:00
naiba 31c3d3bdf1 chore(readme): link order 2025-03-30 17:47:37 +08:00
naiba c52501ae1d chore(readme): 官方论坛 2025-03-29 20:39:24 +08:00
naiba c5a9abda38 chore: remove unused code 2025-03-29 13:18:12 +08:00
naiba 4d2e72c143 fix(nat): nat records not loaded 2025-03-29 13:11:56 +08:00
naiba ad0a4f374d chore: upgrade frontend 2025-03-29 11:29:58 +08:00
Weblate (bot)andAndres Pava 604b03d235 Translated using Weblate (Spanish) (#1048)
Currently translated at 100.0% (60 of 60 strings)


Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/es/
Translation: Nezha/Nezha Dashboard

Co-authored-by: Andres Pava <hola@andrespava.com>
2025-03-29 11:28:06 +08:00
naiba af97e4b17d feat: upgrade frontend 2025-03-27 19:18:14 +08:00
UUBulb 227a6a0cb3 fix search by id (#1047)
* fix search by id

* make SearchByID Public
2025-03-27 19:17:00 +08:00
naiba 33fcf05091 chore: upgrade frontend 2025-03-26 20:23:59 +08:00
naiba f51177bc08 chore: upgrade frontend 2025-03-25 22:51:11 +08:00
UUBulb 3d5faa8c86 add tests for config & iostream (#1045)
* add tests for config & iostream

* chore
2025-03-25 22:44:07 +08:00
naiba ca35040d0b feat: upgrade frontend 2025-03-22 22:44:25 +08:00
dependabot[bot] 97954dc96f chore(deps): bump github.com/golang-jwt/jwt/v4 from 4.5.1 to 4.5.2 (#1041)
Bumps [github.com/golang-jwt/jwt/v4](https://github.com/golang-jwt/jwt) from 4.5.1 to 4.5.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v4.5.1...v4.5.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-22 22:32:30 +08:00
61d4240638 Translated using Weblate (Russian) (#1040)
Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (60 of 60 strings)

Added translation using Weblate (Russian)




Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/ru/
Translation: Nezha/Nezha Dashboard

Co-authored-by: UUBulb <uub@kuzu.uk>
Co-authored-by: bios let <bioslet@gmail.com>
2025-03-22 22:32:12 +08:00
naiba bad68fa9cf chore: upgrade frontend 2025-03-20 23:49:25 +08:00
UUBulb ed533e52d9 fix packaging script (#1037) 2025-03-19 23:33:58 +08:00
naiba 8080fad11d chore: upgrade frontend 2025-03-19 22:23:38 +08:00
UUBulb f34b263b69 improve transfer record logic (#1033)
* improve transfer record logic

* refactor

* modernize loops

* remove unused type conversions

* update dependencies

* script: keep .gitkeep files

* fix

* remove clear
2025-03-19 22:21:21 +08:00
UUBulb ea3739cf1c ddns: support provider hurricane electric (#1036) 2025-03-19 21:52:16 +08:00
UUBulb 49c68f0da4 ddns: retreive dns servers from context (#1034) 2025-03-17 23:11:40 +08:00
naiba 235ee05c91 feat: upgrade frontend 2025-03-15 22:31:16 +08:00
UUBulb 4ce74d9fd8 hide install_host for guest (#1029) 2025-03-15 18:01:23 +08:00
dependabot[bot] 2e8b163945 chore(deps): bump golang.org/x/net from 0.35.0 to 0.36.0 (#1026)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/net/compare/v0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-13 21:31:14 +08:00
naiba 7c857fa4a6 chore: upgrade frontend 2025-03-08 18:48:30 +08:00
UUBulb d0f69c424e generate agent_secret for old users (#1021) 2025-03-08 18:47:42 +08:00
UUBulb f110ff761a fix: ignore the duration of out-of-bound rules (#1019) 2025-03-07 20:23:18 +08:00
UUBulb 0992a85446 fix: oauth2 config not loaded (#1018) 2025-03-04 20:28:22 +08:00
naiba eec7e4f021 chore: handle errors in ReportSystemInfo methods 2025-03-03 22:56:48 +08:00
naiba 712f85aa31 fix: return errors instead of nil in RequestTask and ReportSystemState methods 2025-03-03 22:45:08 +08:00
UUBulb 4d3393271b fix: config fields not generated on first startup (#1016)
* fix: config fields not generated on first startup

* cleanup

* fix reference

* replace yaml module

* remove duplicated fields

* remove yaml.v3 as a direct dependency

* update dependency

* chore
2025-03-03 19:02:25 +08:00
naiba a723893a44 fix: range data point 2025-03-02 16:57:24 +08:00
UUBulb a26337acb2 fix service (#1015) 2025-03-02 16:36:57 +08:00
naiba c3a0689c5d fix: reset ping value correctly after reaching average count in ServiceSentinel 2025-03-02 16:10:33 +08:00
naiba 589191a0bb fix: reset ping value after reaching average count in ServiceSentinel 2025-03-02 15:42:12 +08:00
github-actions[bot] 90e10eb16e update contributors[no ci] 2025-03-02 15:38:01 +08:00
JSker9 cf65101fac feat: add configurable JWT timeout setting (#1014) 2025-03-02 15:37:46 +08:00
UUBulb af55fae652 improve check for offline rules (#1013)
* improve check for offline rules

* bug fixes

* update dependencies

* fix error wrap

* fix offline check

* improve readability
2025-03-02 15:37:21 +08:00
UUBulb d2ffe106ac ci: use go1.24 (#1012) 2025-02-28 22:19:57 +08:00
naiba cdb18eee01 chore: upgrade frontend 2025-02-28 22:05:06 +08:00
UUBulb 043d6c4097 feat: update to go1.24 & support listening https (#1002)
* feat: support listening https

* refactor

* modernize

* support snake case in config

* more precise control of config fields

* update goreleaser config

* remove kubeyaml

* fix: expose agent_secret

* chore
2025-02-28 22:02:54 +08:00
naiba ffd3cfc9da chore: upgrade frontend 2025-02-26 20:55:34 +08:00
UUBulb 54ca4ca0b6 fix: ConfigCache not copied affer server updates (#1008)
* fix: ConfigCache not copied affer server updates

* fix: server list not updated when dispatching tasks

* improve

* reuse logic
2025-02-26 20:48:54 +08:00
UUBulb c0fee50776 fix: possible redirect url inconsistency (#1003) 2025-02-24 20:53:11 +08:00
github-actions[bot] 3f6b001992 update contributors[no ci] 2025-02-23 23:26:28 +08:00
JSker9 e7ab328748 chore: add devcontainer config (#1004) 2025-02-23 23:26:12 +08:00
UUBulb ea068d4db1 refactor: simplify server & service manipulation (#993)
* refactor: simplify server & service manipulation

* update

* fix

* update for nat, ddns & notification

* chore

* update cron

* update dependencies

* use of function iterators

* update default dns servers
2025-02-21 23:08:12 +08:00
naiba 0f1a6edfb1 chore: upgrade frontend 2025-02-20 22:59:38 +08:00
naiba 6c3ebd652d chore: upgrade frontend 2025-02-17 20:24:16 +08:00
UUBulb 7cd8387db8 fix: notifier groups cache not initialized (#995) 2025-02-17 20:14:38 +08:00
naiba f8f4975090 improve: geoip 2025-02-09 14:39:12 +08:00
naiba 91a531a339 chore: upgrade frontend 2025-02-07 22:22:30 +08:00
naiba 72e9676b49 chore: upgrade frontend 2025-02-05 18:50:33 +08:00
naiba fd56256854 release: v1.7.1 2025-02-04 11:28:06 +08:00
UUBulb eeb96caf4d feat: batch set server config (#983)
* feat: batch set server config

* run in parallel

* fix route

* fix

* return some information

* fix order
2025-02-04 11:25:49 +08:00
github-actions[bot] f37b3e768d update contributors[no ci] 2025-02-04 11:24:05 +08:00
Weblate (bot)andUUBulb eb199bb8c0 Translated using Weblate (Chinese (Traditional Han script)) (#981)
Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (English (United States))

Currently translated at 100.0% (60 of 60 strings)

Update translation files

Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.



Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/en_US/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/zh_Hant/
Translation: Nezha/Nezha Dashboard

Co-authored-by: UUBulb <uub@kuzu.uk>
2025-02-04 11:23:41 +08:00
naiba b1f74b501a release: v1.7.0 2025-01-31 13:34:55 +08:00
UUBulb b75a49c3c6 feat: edit server config online (#980)
* feat: edit server config online

* clean

* refactor

* generate template

* fix deadlocks

* fix
2025-01-31 13:33:53 +08:00
UUBulb 2a6206f1a9 ddns: allow overriding domains per configuration (#979) 2025-01-30 12:19:40 +08:00
naiba b4f1c3239a chore: upgrade frontend 2025-01-30 12:17:40 +08:00
naiba 350f080af0 Revert "chore: setup dependabot (#972)" (#978)
This reverts commit 9581690518.
2025-01-29 19:29:00 +08:00
UUBulb ec6b9b9b43 feat: option to force authorization for vistor routes (#971) 2025-01-29 19:27:57 +08:00
UUBulb 66b16e04b6 chore: setup dependabot (#972) 2025-01-29 19:17:44 +08:00
naiba 4a07ee48d7 chore: upgrade frontend 2025-01-29 15:11:23 +08:00
naiba 2356befe71 chore: upgrade frontend 2025-01-28 00:25:16 +08:00
naiba b1db7fc807 chore: upgrade frontend 2025-01-27 22:35:58 +08:00
naiba 104b47d4f4 chore: readme 2025-01-25 15:17:46 +08:00
naiba ee3724a8ae feat: upgrade frontend 2025-01-25 15:12:24 +08:00
naiba 22d7a88318 feat: disable nat 2025-01-21 22:23:15 +08:00
naiba dba7a5a3d6 feat: upgrade frontend 2025-01-21 21:19:42 +08:00
naiba 36e3dab6b2 feat: upgrade frontend 2025-01-20 22:14:06 +08:00
18f8d884b1 Translated using Weblate (Tamil) (#962)
Currently translated at 100.0% (57 of 57 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (57 of 57 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (57 of 57 strings)

Added translation using Weblate (Tamil)




Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/ta/
Translation: Nezha/Nezha Dashboard

Co-authored-by: UUBulb <uub@kuzu.uk>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
2025-01-20 22:09:11 +08:00
github-actions[bot] 73b5fd88d8 update contributors[no ci] 2025-01-19 21:46:26 +08:00
naiba 72ea155340 feat: add nezha-ascii 2025-01-19 21:46:03 +08:00
仓鼠andnaiba 343ae01e38 feat: new theme nezha-ascii (#960)
* feat: new theme nezha-ascii

* chore: not audited

* chore: default

---------

Co-authored-by: naiba <hi@nai.ba>
2025-01-19 21:39:13 +08:00
UUBulb ff4fd31ec4 small improvements (#958)
* small improvements

* fix: return empty iterator if no json present

* use time.Tick

* changes
2025-01-19 21:22:00 +08:00
naiba c3a03a9003 feat: upgrade frontend 2025-01-10 00:24:10 +08:00
naiba 75f2093cd0 feat: upgrade frontend 2025-01-07 20:13:50 +08:00
naiba dd64a4b780 feat: upgrade frontend 2025-01-06 21:07:32 +08:00
naiba 2c9d514527 feat: implement client-side status code handling 2025-01-05 23:53:04 +08:00
naiba f1a0c0696c chore: upgrade frontend 2025-01-05 22:59:07 +08:00
naiba cf0bdf8eee feat: upgrade frontend 2025-01-04 20:52:28 +08:00
UUBulb e3cf4f3794 feat(waf): return ip in string literal (#947)
* feat(waf): return ip in string literal

* rename
2025-01-04 20:49:14 +08:00
naiba 49f0c55350 feat: upgrade frontend 2025-01-04 18:47:00 +08:00
naiba c5cb385dc3 chore: refactor 2025-01-04 12:32:29 +08:00
naiba 60de03142e improve: server list lock 2025-01-04 01:10:16 +08:00
github-actions[bot] 371ec442d6 update contributors[no ci] 2025-01-03 23:22:22 +08:00
naiba 346f89211e feat: upgrade frontend 2025-01-03 23:21:58 +08:00
github-actions[bot] 81cd9eb21e update contributors[no ci] 2025-01-03 22:40:15 +08:00
igophperandigophper 3f89f6a4c8 fix: fix typo in database variable name (#939)
Co-authored-by: igophper <jianghanghui@k.app>
2025-01-03 22:39:55 +08:00
github-actions[bot] 61851ef5c3 update contributors[no ci] 2025-01-03 22:39:44 +08:00
7432b195e4 Translated using Weblate (German) (#945)
Currently translated at 100.0% (57 of 57 strings)

Translated using Weblate (German)

Currently translated at 100.0% (57 of 57 strings)




Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/de/
Translation: Nezha/Nezha Dashboard

Co-authored-by: Barium <peter.kuehfuss+weblate@gmail.com>
Co-authored-by: UUBulb <uub@kuzu.uk>
2025-01-03 22:39:21 +08:00
naiba 67e08695c8 fix: typo 2025-01-01 16:14:19 +08:00
UUBulb 1131f84385 fix: update user cache after profile updates (#936) 2025-01-01 16:11:10 +08:00
github-actions[bot] dea994f4cf update contributors[no ci] 2025-01-01 16:10:58 +08:00
lyj bd534d6bc8 Fix default port (#935) 2025-01-01 16:10:41 +08:00
naiba cd311b4ae6 feat: v1.5.2 2025-01-01 01:02:02 +08:00
UUBulb 178d847836 fix windows ci (#934)
* fix windows ci

* perm

* use custom writer

* remove patches

* fuck codeql
2025-01-01 00:58:57 +08:00
github-actions[bot] 0c64c7234b update contributors[no ci] 2025-01-01 00:02:20 +08:00
Weblate (bot)andUUBulb c1a9d04128 Translated using Weblate (Chinese (Traditional Han script)) (#933)
Currently translated at 100.0% (57 of 57 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (57 of 57 strings)

Translated using Weblate (English (United States))

Currently translated at 100.0% (57 of 57 strings)

Update translation files

Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.



Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/en_US/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/zh_Hant/
Translation: Nezha/Nezha Dashboard

Co-authored-by: UUBulb <uub@kuzu.uk>
2025-01-01 00:01:55 +08:00
naiba 8836d11c22 Merge branch 'master' of github.com:nezhahq/nezha 2024-12-31 23:58:49 +08:00
naiba fcb0ced018 improve: 404 status code 2024-12-31 23:58:44 +08:00
UUBulb 3d591fae78 fix: struct tag (#932)
* fix: struct tag

* fix: struct tag

* fix: strings conversion

* do not write anything after redirection

* fix table

* fix select
2024-12-31 23:09:58 +08:00
UUBulb 2d03c61f38 fix: oauth2 redirect url not consistent (#930)
* fix: oauth2 redirect url not consistent

* only use one redirect uri

* feat: allow to disable password authentication

* generate translation template

* update error

* redirect

* query
2024-12-31 22:29:29 +08:00
naiba ea60a513b8 fix: home page 404 2024-12-30 21:25:22 +08:00
github-actions[bot] 43aaf1ef1b update contributors[no ci] 2024-12-30 20:48:40 +08:00
naiba fa18b644b3 feat: upgrade frontend 2024-12-30 20:48:06 +08:00
quanljh 3cce0b03ca fix: return 404 when page not found (#927) 2024-12-30 20:43:37 +08:00
naiba 8b9afbf39c feat: 绑定 oauth2 2024-12-28 23:50:59 +08:00
naiba 0364632ebc fix: 修复因采样不足导致的告警 2024-12-28 00:29:58 +08:00
naiba 42ef1bcffd improve: check performance 2024-12-28 00:07:07 +08:00
naiba 223f72ef68 fix: rule check 2024-12-27 00:07:44 +08:00
naiba 39b82222dd chore: fix index bug & upgrade frontend 2024-12-26 23:52:44 +08:00
github-actions[bot] 8009b6c410 update contributors[no ci] 2024-12-26 23:38:57 +08:00
UUBulb d70cfe9ee9 bug fixes (#918)
* bug fixes

* fix for backward compatibility

* fix init

* cleanup

* possible fix

* optimize permission check

* Revert "possible fix"

This reverts commit 003f1bbb2aa368aade6702e6019922b7f4871a39.
2024-12-26 23:38:40 +08:00
fa24f90000 Translated using Weblate (Spanish) (#916)
Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (German)

Currently translated at 100.0% (49 of 49 strings)




Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/de/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/es/
Translation: Nezha/Nezha Dashboard

Co-authored-by: Kris <krishoodbyair@gmail.com>
Co-authored-by: UUBulb <uub@kuzu.uk>
2024-12-26 22:32:36 +08:00
naiba 72e3406ac0 feat: upgrade frontend 2024-12-25 21:02:54 +08:00
UUBulb 453c6cdb49 chore: simplify some steps (#912) 2024-12-25 20:24:13 +08:00
UUBulb 2866cc69c3 issue template 必须填写配置 (#913) 2024-12-25 20:19:26 +08:00
naiba 540b339a85 feat: show online user id 2024-12-24 23:28:37 +08:00
UUBulb fa30df0320 feat: support id query for "list" apis (#908)
* feat: support id query for "list" apis

* gosec
2024-12-24 23:23:01 +08:00
naiba 0b54aa070d feat: block user manual & upgrade frontend 2024-12-23 19:50:41 +08:00
naiba f0e1f89903 feat: upgrade frontend 2024-12-23 00:40:38 +08:00
github-actions[bot] 76df7c3017 update contributors[no ci] 2024-12-22 23:38:43 +08:00
naiba 5041c385a9 feat: upgrade frontend 2024-12-22 23:38:18 +08:00
naiba eaf57ec1ab chore: api doc 2024-12-22 23:20:49 +08:00
仓鼠 2dd53c7f38 fix: batch-block online-user request method (#903) 2024-12-22 23:18:08 +08:00
naiba 0764dac3b3 refactor: improve performance 2024-12-22 17:23:55 +08:00
naiba 5158375448 fix: install host 2024-12-22 16:25:00 +08:00
naiba fafb075818 Merge branch 'master' of github.com:nezhahq/nezha 2024-12-22 16:17:19 +08:00
naiba 04ce6f5850 fix: install host 2024-12-22 16:17:13 +08:00
github-actions[bot] 3dc9395d28 update contributors[no ci] 2024-12-22 15:52:07 +08:00
naiba ea43234797 fix: create user form 2024-12-22 15:51:37 +08:00
Weblate (bot)andUUBulb c21f9e116e Translated using Weblate (Chinese (Traditional Han script)) (#901)
Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (English (United States))

Currently translated at 100.0% (49 of 49 strings)

Update translation files

Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.



Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/en_US/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/zh_Hant/
Translation: Nezha/Nezha Dashboard

Co-authored-by: UUBulb <uub@kuzu.uk>
2024-12-22 13:03:27 +08:00
UUBulb a935693677 chore: update translation template (#900) 2024-12-22 12:24:39 +08:00
naiba 8c5023a650 feat: enhance authorization check for settings access 2024-12-22 12:20:18 +08:00
naiba 6dea3e19d2 feat: list & block online users 2024-12-22 01:08:07 +08:00
UUBulb dc0132c9aa feat: user roles (#852)
* [WIP] feat: user roles

* update

* update

* admin handler

* update

* feat: user-specific connection secret

* simplify some logics

* cleanup

* update waf

* update user api error handling

* update waf api

* fix codeql

* update waf table

* fix several problems

* add pagination for waf api

* update permission checks

* switch to runtime check

* 1

* cover?

* some changes
2024-12-22 00:05:41 +08:00
naiba 296b9c20e9 feat: upgrade frontend 2024-12-21 14:42:08 +08:00
naiba 09ad9b30da feat: add online user count 2024-12-21 10:59:05 +08:00
naiba 6a8b00bf44 feat: upgrade frontend 2024-12-20 23:34:59 +08:00
naiba f5fb846470 feat: upgrade frontend 2024-12-20 22:53:40 +08:00
naiba eaef41c3b0 shit tp-link 2024-12-20 22:44:14 +08:00
github-actions[bot] 59c3c63070 update contributors[no ci] 2024-12-20 22:37:54 +08:00
UUBulb 0976b0ea37 chore: update admin frontend screenshot (#893) 2024-12-20 22:37:28 +08:00
github-actions[bot] 7fb155183d update contributors[no ci] 2024-12-19 23:22:15 +08:00
naiba c9056eaa44 refactor: fallback agent ip to connecting ip 2024-12-19 23:21:31 +08:00
naiba 7d689862d8 fix: rule snapshot 2024-12-19 23:21:15 +08:00
naiba 03aa75dc01 feat: upgrade frontend 2024-12-17 22:20:02 +08:00
naiba ffa57cafab feat: upgrade frontend 2024-12-17 22:05:41 +08:00
github-actions[bot] 9a3a3f102d update contributors[no ci] 2024-12-17 21:46:43 +08:00
6074d3e094 Translations update from Hosted Weblate (#580)
* Translated using Weblate (Spanish)

Currently translated at 100.0% (45 of 45 strings)

Translated using Weblate (German)

Currently translated at 100.0% (45 of 45 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (45 of 45 strings)

Added translation using Weblate (Spanish)

Translated using Weblate (German)

Currently translated at 100.0% (45 of 45 strings)

Added translation using Weblate (German)

Co-authored-by: Barium <peter.kuehfuss+weblate@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kris <krishoodbyair@gmail.com>
Co-authored-by: UUBulb <uub@kuzu.uk>
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/de/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/es/
Translation: Nezha/Nezha Dashboard

* feat: add german support

---------

Co-authored-by: Barium <peter.kuehfuss+weblate@gmail.com>
Co-authored-by: Kris <krishoodbyair@gmail.com>
Co-authored-by: UUBulb <uub@kuzu.uk>
2024-12-17 21:46:25 +08:00
naiba 5be1b6455b feat: upgrade frontend 2024-12-16 23:44:51 +08:00
naiba ce6cdc229f feat: upgrade frontend 2024-12-16 21:29:23 +08:00
naiba 9697dc14a5 feat: upgrade frontend 2024-12-16 21:10:10 +08:00
naiba b5e73d7596 feat: upgrade frontend 2024-12-16 00:05:55 +08:00
naiba f59b69dfcf feat: upgrade frontend 2024-12-15 20:29:51 +08:00
naiba bbf0f96b8d feat: update language handling and frontend template versions 2024-12-15 17:07:53 +08:00
naiba bbb62b7a4c feat: upgrade frontend 2024-12-15 11:00:49 +08:00
naiba 4d09060c69 feat: upgrade frontend 2024-12-14 11:00:48 +08:00
naiba d02c8f9fd1 chore: update dependency 2024-12-14 00:07:37 +08:00
UUBulb 5f5620b352 fix: add nil check for ReportSystemState (#583) 2024-12-13 22:27:30 +08:00
UUBulb 8dd7c609f5 fix: custom_nameservers should be dns_servers (#581) 2024-12-13 22:25:20 +08:00
naiba d0b5549cdb feat: enhance template configuration and update README for translation status 2024-12-10 22:27:06 +08:00
Moraxyc 67190459f8 Refactor: Load UserTemplates from embedded yaml file (#575)
* Refactor: Load UserTemplates from embedded yaml file

* feat: add version field to UserTemplates

* refactor: use shell script to fetch frontends

* chore: add *-dist to .gitignore

* refactor: rename to FrontendTemplates

BREAKING CHANGE: This commit changes the `user_templates` filed
in the communication json between backend and the admin-frontend.

Keep user config.yml `user_template` filed.
2024-12-10 21:57:20 +08:00
github-actions[bot] 7640a6ae0b update contributors[no ci] 2024-12-09 20:42:19 +08:00
naiba 5ef21497c2 feat: upgrade frontend 2024-12-09 20:41:48 +08:00
Weblate (bot)andUUBulb 08232a621d Translated using Weblate (Chinese (Traditional Han script)) (#570)
Currently translated at 100.0% (45 of 45 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (45 of 45 strings)


Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/nezha/nezha-dashboard/zh_Hant/
Translation: Nezha/Nezha Dashboard

Co-authored-by: UUBulb <uub@kuzu.uk>
2024-12-08 22:27:00 +08:00
UUBulb 9c393de0c9 mention weblate & add weblate badge (#569)
* mention weblate & add weblate badge

* remove installer badge

* delete outdated stuff
2024-12-08 20:52:55 +08:00
UUBulb 3e3994d3c9 chore: use cmp (#568) 2024-12-08 20:21:35 +08:00
naiba d143b38ed0 revert: keep v0 2024-12-07 12:39:30 +08:00
naiba 7c8cfc4b2f feat: upgrade frontend 2024-12-07 12:19:48 +08:00
naiba 29d0d2eb3c feat: 优化 GeoIP 更新逻辑 2024-12-07 11:06:42 +08:00
naiba 55a867d077 feat: v1.1 2024-12-07 01:18:34 +08:00
naiba fc69eb075f feat: add nazhua theme 2024-12-06 23:45:12 +08:00
UUBulb 20e1240d3c UserTemplate GitHub -> Repository (#564) 2024-12-06 23:38:20 +08:00
naiba dd964f3b26 feat: multi user template 2024-12-06 23:19:28 +08:00
naiba e2d1ad43c3 feat: upgrade frontend 2024-12-05 21:40:07 +08:00
github-actions[bot] aa98587da5 update contributors[no ci] 2024-12-05 21:14:41 +08:00
naiba 8b15c24ca5 feat: setup pat 2024-12-05 21:14:10 +08:00
UUBulb 4ddad9ea00 fix service api (#556)
* fix service api

* update ServiceResponseItem

* fix: ddns lock
2024-12-05 21:00:02 +08:00
UUBulb 47099b617e fix: allocate geoip on server creation (#554) 2024-12-05 17:07:31 +08:00
Moraxyc 5a706b05c7 feat: add listen_host (#550) 2024-12-05 17:01:21 +08:00
naiba 4861b3e013 feat: refactor grpc keepalive 2024-12-05 00:11:34 +08:00
naiba b59094fd19 Revert "feat: grpc keepalive"
This reverts commit b484fe53f0.
2024-12-04 22:46:02 +08:00
naiba 30df69d72d feat: grpc keepalive 2024-12-04 21:08:28 +08:00
naiba 4273b6a90b feat: upgrade frontend 2024-12-04 20:37:29 +08:00
榆木 82ac77b35a Update waf.go (#548)
WAF查询IP是否被封禁导致控制台大量报错,换一个查询方式。
2024-12-04 20:00:35 +08:00
UUBulb f5b7c2be13 update ws & settings api (#547) 2024-12-04 20:00:18 +08:00
naiba e70f52ec93 feat: upgrade frontend 2024-12-03 20:17:11 +08:00
naiba e3bb101206 feat: upgrade frontend 2024-12-02 23:55:12 +08:00
naiba a47f9acab4 feat: upgrade frontend 2024-12-02 21:20:23 +08:00
UUBulb dba9226c7c fix config default value (#538)
* fix config & state fields

* revert state
2024-12-02 21:10:40 +08:00
UUBulb 1b02fda2c8 chore: issue template typos (#529) 2024-12-02 20:52:51 +08:00
UUBulb 65340c10ce fix: allocate memory for geoip struct of new server instance (#530) 2024-12-02 20:52:10 +08:00
UUBulb 8f173f70cb fix server deletion api, add issue templates (#526)
* add issue templates

* fix: server deletion api
2024-12-01 23:45:26 +08:00
UUBulbandnaiba 6248c38830 fix(i18n): replace hyphen with underscore (#524)
* fix(i18n): repleace hyphen with underscore

* chore: upgrade user frontend

---------

Co-authored-by: naiba <hi@nai.ba>
2024-12-01 23:19:06 +08:00
naiba 4244b26a31 fix: service item display logic 2024-12-01 21:57:13 +08:00
卖女孩的小火柴andgithub-actions[bot] 95f49bdb0b Feature/v0 scripts (#512)
* update contributors[no ci]

* Add v0 release

* Add install.sh to keep the v0 panel functioning properly

* Modify sync.py

* Remove release-v0.yaml

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-01 21:52:38 +08:00
严浩 fbb96f2e27 fix: use exec to properly handle container signals (#501)
- Replace direct command execution with exec to make app PID 1
- Ensures proper signal propagation in container
- Follows container best practices for process management
2024-12-01 00:47:41 +08:00
naiba 2438f835f5 fix: checkorigin when debug enabled & update fronted dist 2024-12-01 00:00:01 +08:00
naiba cb2ed77747 fix: dashboard waf page 2024-11-30 21:33:18 +08:00
naiba 45138b8a4f chore: upgrade admin frontend 2024-11-30 17:18:11 +08:00
naiba e8c9966e25 fix: admin frontend 2024-11-30 15:19:55 +08:00
naiba bbcfd6a83b feat: 支持修改用户名 2024-11-30 15:05:46 +08:00
naiba 38d898c078 chore: upgrade admin frontend 2024-11-30 13:50:46 +08:00
naiba faa57330f8 feat: update frontend 2024-11-30 11:46:28 +08:00
naiba 21b40fbcb7 fix: windows path issue 2024-11-30 00:02:45 +08:00
naiba 844947f89b fix: home page 500 2024-11-29 22:49:17 +08:00
naiba a3a7d70c8c fix: swago 2024-11-29 21:55:29 +08:00
naiba b5d70734f4 fix: ci 2024-11-29 21:42:18 +08:00
naiba 9ba3a4fb2b feat: embed dist 2024-11-29 21:31:39 +08:00
naiba d74d03fe75 fix: aliyun docker url 2024-11-29 00:58:20 +08:00
UUBulb ccdec6182b ci: fix arm64 path parsing (#487) 2024-11-29 00:31:20 +08:00
naiba cd62cac4c5 fix: typo 2024-11-28 21:10:05 +08:00
naiba c49f6ead29 fix: type 2024-11-28 20:45:03 +08:00
naiba 3038c23a1e chore: update readme 2024-11-28 20:43:07 +08:00
naiba 04f33eb4f9 Revert "Fix code scanning alert no. 23: Uncontrolled data used in path expression (#486)"
This reverts commit c2b3d19a51.
2024-11-28 20:38:02 +08:00
naibaandCopilot Autofix powered by AI 8f44c76e4f Fix code scanning alert no. 23: Uncontrolled data used in path expression (#486)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2024-11-28 20:26:51 +08:00
dependabot[bot] b9e8e5ad3b chore(deps): bump github.com/golang-jwt/jwt/v4 from 4.5.0 to 4.5.1 (#485)
Bumps [github.com/golang-jwt/jwt/v4](https://github.com/golang-jwt/jwt) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v4.5.0...v4.5.1)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-28 20:23:41 +08:00
naiba 53dd09fdad chore: dir perm 2024-11-28 20:15:12 +08:00
naiba 49f881c149 update frontend 2024-11-28 19:48:08 +08:00
naiba 986eb0b340 Revert "ci: comment out costly things (#4)"
This reverts commit 3168055948.
2024-11-28 19:43:50 +08:00
naiba a7e4fe0909 rename repo 2024-11-28 19:38:54 +08:00
UUBulb ee725bd337 fix: server api schema (#23) 2024-11-28 17:06:54 +08:00
naiba 29807fc2a6 refactor: update release workflow to send and download artifacts to self-hosted storage 2024-11-26 21:59:11 +08:00
naiba 0401c98d09 [temp] use self-hosted storage 2024-11-26 21:54:49 +08:00
UUBulb 4fed62db46 send country code in ws, update profile api (#22)
* send country code in ws

* make ddns_profiles optional field

* update profile api
2024-11-26 21:30:56 +08:00
UUBulb 48b475925b fix: only close user/agentIo connect channel once (#21) 2024-11-26 09:18:13 +08:00
naiba 116a56868f fix: update release workflow repository and enhance user deletion logic 2024-11-25 22:24:33 +08:00
naiba 5846c682d3 refactor: replace pflag with standard flag package in main.go 2024-11-25 22:03:11 +08:00
UUBulb eca8b65e7c fix netip parsing (#20) 2024-11-25 21:20:08 +08:00
UUBulb 70ded7760d fix: ensure the configuration path exists (#19) 2024-11-25 20:18:30 +08:00
UUBulb a1fca78dab apply upstream changes (#18)
* apply upstream changes

* update translations, fix i18n script

* remove installation scripts and related files
2024-11-24 01:07:14 +08:00
naiba e350f1beec feat: release 2024-11-23 22:06:08 +08:00
UUBulb eb28e34e01 update setting api (#17) 2024-11-23 16:36:16 +08:00
UUBulb e465134c11 update profile api (#16)
* update profile api

* rename

* fix realip assertion

* add waf api
2024-11-23 16:22:22 +08:00
naiba 6db1a4b19d improve: use stream reduce auth check time 2024-11-23 12:43:02 +08:00
naiba 336d8d4fc3 fix: waf condition 2024-11-23 10:21:01 +08:00
UUBulb e4c6502363 optimize sorting, fix a bug (#15) 2024-11-23 01:13:39 +08:00
naiba 0da41bc471 refactor: ip data type 2024-11-23 00:58:15 +08:00
naiba 293391a695 refactor code 2024-11-23 00:02:57 +08:00
naiba 546685320f Merge branch 'dev' of github.com:naiba/nezha-v1 into dev 2024-11-22 23:57:48 +08:00
naiba 909cd92e9a feat: waf 🤡 2024-11-22 23:57:25 +08:00
UUBulb 13810bb38b report geoip separately, fix server creation & deletion bugs (#14)
* new geoip method

* report geoip separately, fix server creation & deletion bugs

* fix struct tag

* fix write name

* remove deleteion list

* remove rpc realip header

* Revert "remove rpc realip header"

This reverts commit 8a5f86cf2d7df87f28cfa2a3b3430f449dd6ed73.
2024-11-22 22:40:43 +08:00
naiba f5776c7464 feat: grpc / web get real ip 2024-11-22 00:19:36 +08:00
naiba da4cdb8fe6 fix: file exists 2024-11-20 23:14:17 +08:00
naiba f3a32f55e9 fix: add pat 2024-11-20 23:10:11 +08:00
naiba 484919a79f chore: fix typo 2024-11-20 22:53:21 +08:00
naiba 8025793ead fix: download admin frontend 2024-11-20 22:40:30 +08:00
naiba 71020c7a26 chore: update frontend release 2024-11-20 22:37:03 +08:00
UUBulb 1c3fd9af1c add force update server api (#13) 2024-11-20 21:36:21 +08:00
UUBulb c74987490d fix alert rule api (#12) 2024-11-20 13:44:15 +08:00
UUBulb b80e251f6a remove id field in every form struct, fix cron & ddns db (#11) 2024-11-19 23:34:56 +08:00
UUBulb 50ec515b55 disable soft deletion in group api (#10)
* disable soft deletion in group api

* remove DeleteAt

* remove DeleteAt service
2024-11-19 21:41:21 +08:00
UUBulb 35126fa700 allow cors from loopback addresses in debug mode (#9) 2024-11-18 13:26:41 +08:00
UUBulb ad1c9567ef fix service tags (#8) 2024-11-17 00:40:40 +08:00
UUBulb 5c1599c88c fix struct tags (#7) 2024-11-16 20:57:03 +08:00
UUBulb 580815853b fix: add json tag for service response (#6) 2024-11-16 09:08:18 +08:00
UUBulb 0775a1a388 swag: add --requiredByDefault option (#5) 2024-11-15 20:45:27 +08:00
UUBulb 997ae8d8e4 ci: comment out costly things (#4) 2024-11-10 07:09:38 +08:00
naiba 551e826a35 fix: update refresh token endpoint to use hyphen 2024-11-08 23:57:15 +08:00
naiba 323f4d25c2 feat: profile api 2024-11-03 23:28:10 +08:00
UUBulb 333aa59eb6 Merge pull request #3 from uubulb/dev
fix ReSortServer
2024-11-03 21:14:10 +08:00
uubulb 5df1638648 fix ReSortServer 2024-11-03 21:12:28 +08:00
naiba 23e5f5d95d 😢 前端暂时无了 2024-11-01 21:23:06 +08:00
naiba 59d4309e85 feat: add i18n support 2024-11-01 21:14:00 +08:00
uubulb c8078b226c remove old sources 2024-11-01 16:31:26 +08:00
uubulb 48512087b1 set c.Language to zh_CN if not specified 2024-11-01 14:36:14 +08:00
uubulb 96bec45855 add zh_TW locale, rename ssl to tls 2024-11-01 14:32:55 +08:00
uubulb d56224a118 feat: add i18n support 2024-11-01 05:07:04 +08:00
naiba 8fccfa1df8 Merge pull request #1 from uubulb/dev 2024-10-31 08:47:26 +08:00
uubulb 1c308929ee merge upstream, improve geoip pkg 2024-10-31 03:34:25 +08:00
github-actions[bot] 2f5516e67f update contributors[no ci] 2024-10-30 16:15:58 +00:00
Moraxyc b6d9038a11 fix(cli): display version before init app (#463) 2024-10-31 00:15:41 +08:00
naiba a593daeab2 fix: typo 2024-10-30 23:31:04 +08:00
naiba ffe020b2d8 fix ci 2024-10-30 23:22:37 +08:00
naiba a6047de2ba fix ci 2024-10-30 23:13:04 +08:00
naiba 57bbd4847b fix ci 2024-10-30 22:50:51 +08:00
UUBulb 2fb83da047 ddns: replace tencentcloud implemention (#462) 2024-10-29 21:39:45 +08:00
naiba 21bba14a6a format: json marshal 2024-10-29 10:21:25 +08:00
naiba 5aafda95ff fix: migration 2024-10-28 14:42:36 +08:00
naiba f925266aed fix 2024-10-27 14:49:33 +08:00
UUBulbandnaiba 029457831d add "network page" api (#460)
* add network api

* a minor change

* only show service name if unauthorized

* update

* 除了 load/初始化 避免在 singleton 进行查询等操作

---------

Co-authored-by: naiba <hi@nai.ba>
2024-10-27 14:43:37 +08:00
UUBulb 70b914d535 add setting api (#461) 2024-10-27 13:10:07 +08:00
UUBulb 8bfe70d519 add cron, nat api & refactor alert rule (#459)
* add cron api & refactor alert rule

* add nat api

* fix swagger

* remove unnecessary steps
2024-10-26 23:57:47 +08:00
UUBulb 785b4dce37 add alert api (#458) 2024-10-26 08:16:57 +08:00
naiba adb69d76b0 update admin-dist repo 2024-10-25 22:58:29 +08:00
UUBulb 0abccf43f6 prevent writing response to websocket connections (#457) 2024-10-25 21:45:05 +08:00
UUBulb 800412a2c8 add fm api (#456) 2024-10-25 09:09:08 +08:00
naiba cda910fb84 fix: swagger doc 2024-10-25 00:19:44 +08:00
naiba 868c242eb3 refactor: rename monitor -> service 2024-10-25 00:13:45 +08:00
naiba bd4349ee58 fix dist folder 2024-10-24 22:50:57 +08:00
naiba 2d8ec03e0d fix: tag name 2024-10-24 22:44:13 +08:00
naiba ed9050ad57 feat: update admin-dist 2024-10-24 22:35:26 +08:00
naiba e8d043c2d6 fix: release 2024-10-24 22:25:12 +08:00
naiba 4232c7cba1 list go bin 2024-10-24 22:19:51 +08:00
naiba 98eab65fbd fix ci 2024-10-24 22:15:37 +08:00
naiba c93ec14bc1 feat: dev docker 2024-10-24 21:33:36 +08:00
UUBulb ce2b5ed549 update grpc-go & protobuf definition (#455) 2024-10-24 20:41:47 +08:00
UUBulb d742b605a5 fix swaggo (#454) 2024-10-24 14:11:06 +08:00
UUBulb 8b24a30bee fix list apis (#453) 2024-10-24 10:21:59 +08:00
naiba 69799c28d4 🚀 monitor api 2024-10-23 23:06:11 +08:00
UUBulb 28b606ef7b implement notification group (#450)
* implement notification group

* some fixes

* fix sql

* add listNotification

* retrieve notification from map

* create notification_group_notification if non-exist

* NotificationIDToGroup -> NotificationIDToGroups

* clean
2024-10-23 21:55:12 +08:00
naiba c333f350c0 Merge branch 'master' into dev 2024-10-23 21:44:54 +08:00
naiba fa14043cbe refactor nat 2024-10-23 20:37:29 +08:00
naiba dd41017634 💄 refactor common handler 2024-10-23 17:56:51 +08:00
naiba c369f9cba4 feat: list server api 2024-10-23 17:34:15 +08:00
UUBulb 1eaf70782a add path check for multiplexer (#451) 2024-10-23 12:55:10 +08:00
naiba 6615505ab9 fix ci 2024-10-23 00:03:19 +08:00
naiba c5bacf3e84 fix agent connect 2024-10-22 23:44:50 +08:00
naiba 62a0b32577 feat: terminal api 2024-10-22 22:01:01 +08:00
naiba c706950ecc feat: create user, read users, batch delete users 2024-10-22 21:19:30 +08:00
naiba 602fbebcda fix login / refresh 2024-10-22 21:07:41 +08:00
UUBulb 1ada3f5b8b installer: fix download service scripts (#449) 2024-10-22 13:02:54 +08:00
UUBulb 08dea15cbf use plain error type for expected behaviors (#447) 2024-10-22 10:25:45 +08:00
UUBulb 1ad7dcffd2 installer: fix format (#448) 2024-10-22 10:25:31 +08:00
UUBulb 05b716d80f ddns: add listProviders api (#446)
* ddns: add listProviders api

* fix swagger
2024-10-22 00:04:17 +08:00
naiba 0a859c75d7 fix ci 2024-10-21 23:14:31 +08:00
naiba d8f36a1665 fix ci 2024-10-21 23:04:38 +08:00
naiba 821baba639 feat: server group api 2024-10-21 23:00:51 +08:00
UUBulb ec83ff0617 dev: add ddns list api (#445) 2024-10-21 16:22:30 +08:00
UUBulb 4061b847b2 dev: add ddns create, edit and batch delete api (#444) 2024-10-21 14:30:50 +08:00
UUBulb 2699627df5 add a helper function (#443) 2024-10-21 12:11:02 +08:00
naiba 80740ddf39 refactor agent auth & server api 2024-10-20 23:23:04 +08:00
naiba fa1d567f2e fix ci 2024-10-20 14:09:29 +08:00
naiba 71891b2d61 refactor: login & refresh token 2024-10-20 14:05:43 +08:00
naiba eabe55a753 Merge branch 'master' into dev 2024-10-20 11:51:58 +08:00
naiba 56d3936797 refactor graceful shutdown 2024-10-20 11:50:02 +08:00
naiba 542f06551e refactor: remove pages, combine grpc http port 2024-10-20 11:47:45 +08:00
UUBulb ab817e9ddd fix installer (#442)
* fix installer

* version
2024-10-20 00:33:28 +08:00
naiba 17a04757a0 model redesign 2024-10-20 00:32:55 +08:00
naiba 8849cb51e6 init jwt 2024-10-20 00:09:16 +08:00
naiba e652ea3008 fix ci 2024-10-19 23:17:14 +08:00
naiba 37c87bbc07 🎉 init swaggo 2024-10-19 23:14:53 +08:00
UUBulb ca16505dee installer: fix some style issues (#441)
* installer: fix some style issues

* translate

* translate

* replace cf-ns
2024-10-19 22:03:00 +08:00
UUBulb efc08bdab7 fix: add custom theme key (#440) 2024-10-19 11:59:31 +08:00
UUBulb 643c6dd3a1 ddns: remove ipv6 nameservers, support custom nameservers (#439) 2024-10-18 22:06:01 +08:00
UUBulb 714649df5e do not check description file for theme-custom (#437)
* do not check description file for theme-custom

* deprecation note
2024-10-18 12:26:59 +08:00
UUBulb 6ce6c036e0 fix(ddns): handle second-level domain correctly (#438) 2024-10-18 12:26:43 +08:00
UUBulb 2ee92ed956 fix(ddns): add missing field WebhookRequestType (#436)
* fix(ddns): add missing field WebhookRequestType

* add missing placeholders
2024-10-17 23:35:28 +08:00
UUBulbandnap0o bf9c6d669a ddns: store configuation in database (#435)
* ddns: store configuation in database

Co-authored-by: nap0o <144927971+nap0o@users.noreply.github.com>

* feat: split domain with soa lookup

* switch to libdns interface

* ddns: add unit test

* ddns: skip TestSplitDomainSOA on ci

network is not steady

* fix error handling

* fix error handling

---------

Co-authored-by: nap0o <144927971+nap0o@users.noreply.github.com>
2024-10-17 21:03:03 +08:00
UUBulb 9a65c4ea95 fix dashboard custom theme, expose HideForGuest for api (#434)
* fix: dashboard custom theme

* api: expose HideForGuest
2024-10-10 13:38:09 +08:00
UUBulb 427e0e5449 feat: description file for custom theme; use gjson (#433)
* feat: description file for custom theme; use gjson

* fix gosec

* remove outdated stuff
2024-10-10 00:08:16 +08:00
nap0o 889878993a improve: status-server主题日常优化 (#432)
* improve: status-server主题日常优化
1. 首页账单信息:价格设置成“-1”前台展示为”PAYG“,自动续期(autoRenewal)功能增加支持付费周期yr,mo;
2. 当未获取到国旗图标时,默认彩虹旗;
3. 当未获取到os(操作系统)信息时,默认linux
4. 一些其他优化

* 修改表达不准确的语言文件
2024-10-08 11:39:44 +08:00
github-actions[bot] 9db4d58977 update contributors[no ci] 2024-10-06 12:54:13 +00:00
UUBulbandnaiba 7db5618333 chore: l10n: improve translation (#431)
* l10n: improve translation

* refine

---------

Co-authored-by: naiba <hi@nai.ba>
2024-10-06 20:53:55 +08:00
zhucaidanandnaiba 37184a9e20 Update en-US.toml (#429)
* Update en-US.toml

Optimize translation

* refine

---------

Co-authored-by: naiba <hi@nai.ba>
2024-10-06 11:41:19 +08:00
UUBulb 47f961f530 installer: fix docker-compose detection logic (#428) 2024-10-06 09:35:12 +08:00
UUBulb 5a31feb364 installer: fix docker-compose detection (#427) 2024-10-05 23:43:16 +08:00
nap0o 9cc30fb971 improve: status-server主题agent账单信息展示 (#425)
1. 支持价格和期限单独设置
2. 账单信息附加到group分组控制
3. 一些其他优化
2024-10-04 23:35:44 +08:00
github-actions[bot] ee758dcf85 update contributors[no ci] 2024-10-04 15:33:58 +00:00
lyjandnaiba f1cba86db0 fix: 允许更新默认分组 (#426)
* fix: 允许更新默认分组

* improve: check null

---------

Co-authored-by: naiba <hi@nai.ba>
2024-10-04 23:33:40 +08:00
naiba 4417eefdfe chore: ignore gosec errors 2024-10-02 10:10:06 +08:00
nap0o 54908da278 feat: status-server主题增加agent账单信息展示 (#424)
1. 首页通过在后台配置PublicNote字段,实现agent账单信息展示
2. 优化顶部导航,增加当前位置标识
3. 优化network页移动端样式
4. 优化后台server页展示,隐藏掉一些不重要的信息
5. 一些其他小优化
使用说明:https://github.com/naiba/nezha/pull/424#issuecomment-2386837658
2024-10-02 10:05:53 +08:00
naiba a93147086e Refactor file compression logic in release workflow 2024-09-30 20:49:51 +08:00
naiba 2673089b57 🐛 修复展示对游客隐藏服务器 2024-09-28 01:19:02 +08:00
naiba db51b4111b ✨ 增加透传给前端的自定义字段方便前端扩展功能 2024-09-28 00:16:54 +08:00
UUBulb aa6879a666 improve: use sync.Pool for buffer allocation (#423) 2024-09-27 18:50:19 +08:00
nap0o 6ec90da6af improve: status-server主题network页 (#422) 2024-09-11 21:50:40 +08:00
nap0o 0b03b86fe0 Default主题fixbug (#421)
* default主题fixbug
1.补上误删的网页title
2.修复network页纵坐标数值显示不全
3.修复network,多个legend导致的遮挡主图表问题

* fix

* fix

* fix

* fix

* fix

* fix
2024-09-07 14:44:30 +08:00
github-actions[bot] 3bb6de725c update contributors[no ci] 2024-09-06 15:31:54 +00:00
nap0o a84cf20c3a server-status和default主题:feat & improve & fix (#420)
* feat & improve & fix
1. 增加WebAppManifest文件,实现把哪吒监控网页伪装成app放在移动端桌面
2. vps地图分布图增加数据异步加载loading效果
3. 修复echart图表纵坐标轴因数值过大显示不全bug

* 刷新CDN缓存

* fix footer

* 4.提升service页echarts图表相关体验
5.用设置基准1vh的方法解决footer页脚位置在移动端各种浏览器显示不一致的问题
6.修复section标签的使用位置

* 刷新CDN缓存
2024-09-06 23:31:38 +08:00
UUBulb f80927b75e fix dashboard install script (#419) 2024-09-03 08:54:07 +08:00
UUBulb 3edfc8c14b fix: docker image (#418) 2024-09-02 23:19:43 +08:00
UUBulb 8424c4e756 [ci][docker]: use matrix to reduce build time & change base image to busybox (#417)
* ci: use matrix to reduce build time

* docker: change base image to busybox

* reuse workflows

* only download ipinfo db on tag pushes

* goreleaser
2024-09-02 22:13:13 +08:00
UUBulb 2826d934ca fix/fm: reset upload state after completion (#416) 2024-08-28 23:23:23 +08:00
UUBulb a4709904cb 修复多CPU在默认主题和AngelKanade下显示的问题,完善内网穿透本地化文本 (#415) 2024-08-25 11:33:25 +08:00
UUBulb 911bce71ff refactor: ddns (#414)
* refactor ddns

* update webhook
2024-08-24 11:11:06 +08:00
naiba cc38d858f2 chore: back to attach addon 2024-08-21 23:42:05 +08:00
naiba 4214e26673 chore: 移除 trzsz 支持 (不能上传) 2024-08-21 22:20:47 +08:00
UUBulb af07b54190 fm: store file to OPFS temporarily (#413) 2024-08-21 22:19:40 +08:00
github-actions[bot] b2b22cab4e update contributors[no ci] 2024-08-20 14:25:53 +00:00
UUBulb f60d344635 feat: framed fm for webshell (#411)
* feat: framed fm for webshell

* 1MB buffer
2024-08-20 22:25:29 +08:00
UUBulb a30744ac1f ci增加重传机制 (#410) 2024-08-18 08:56:52 +08:00
nap0o b118046097 improve & fix : 主题优化及bug修复 (#409)
* 主题优化及bug修复

default主题
1.修复主box过宽导致的系列问题(pc和移动端)
2.network页面适配深色模式和浅色模式

status-server主题
1. network页面折线图取数逻辑优化,丢包率一直是100%时,不显示丢包markline线
2. 隐藏所有table横向滚动条
3. 关闭折线图hover效果,大幅提升echarts图表渲染速度
4. 移动端页底显示位置优化

* 刷新cdn缓存

* 修复default深色模式在ios设备不生效bug

* 1. 恢复主baox 1680px宽度,用其他方式修复上一版存在bug(移动端左右留空不一致等)
2. 首页echarts自动适配深色浅色模式

* fix
2024-08-17 11:14:28 +08:00
naiba 75a75758df feat: 管理后台自定义代码 2024-08-14 00:24:17 +08:00
naiba 7ab2252862 feat: 默认主题 dark mode 2024-08-13 23:50:55 +08:00
naiba 8f823fc7f0 chore: set ws binaryType 2024-08-13 13:53:44 +08:00
github-actions[bot] d7c98408c0 update contributors[no ci] 2024-08-13 05:51:41 +00:00
hiDandelion 6b3743f39f Add replacing fields to notification content in notification.go (#408) 2024-08-13 13:51:19 +08:00
naiba 1663f3bd79 feat: WebSSH 上传下载文件 (by trzsz) 2024-08-13 00:01:39 +08:00
naiba e0599e7a38 fix: 流量统计异常 2024-08-12 10:06:55 +08:00
naiba 4546524df0 improve: 优化可能造成流量统计异常的情况 2024-08-11 10:35:19 +08:00
nap0o cb3355c291 server-status主题重写network页面 (#407)
* 修复浏览器高分辨率下网络页echart图表显示不全

* server-status主题重写network页面
1. 点击“网络监控”弹出选择服务器
2. 增加服务器搜索筛选功能
3. 自适应lenged高度
4. 适配深色模式与浅色模式
5. 适配半透明模式
6. 适配pc段与移动端

* 刷新cdn缓存

* 增加disposeCharts逻辑

* 增加disposeCharts逻辑

* 标题增加服务器所在地区国旗展示

* 国旗样式修复

* 修复国旗样式

* echart图标高度适配不同分辨率浏览器
2024-08-10 23:19:52 +08:00
nap0o 0f4c1b6ab4 server-status主题优化 (#405)
* server-status主题优化
1.前台增加半透明样式切换按钮,同时适配了深色和浅色模式
2.右小角功能区增加收纳,点击展开,滚动页面关闭
3.vps世界分布地图全屏展示
4.修改默认页底版权信息位置,从跟随container box修改为默认置于页面底部,container box到底底部后再跟随
5.修改container box默认宽度和最大宽度,适配高分辨率显示器
6.优化样式文件结构,删除无用的样式文件
7.一些小优化

* 修改默认背景图

* 修改背景图片

* 1.echart图表适配半透明样式切换
2.echart图表移动端设置更细折线
3.修改半透明样式下的默认背景图

* echart Y轴移动端更细刻度线

* fixbug

* 修改半透明样式默认背景图片为本地调用
2024-08-09 21:49:45 +08:00
UUBulb eb60a1afc8 ws: ignore all DDNS fields (#406) 2024-08-09 21:47:29 +08:00
UUBulb 5c3f68d34d installer: fix geo_check (#404) 2024-08-05 10:21:14 +08:00
github-actions[bot] c19c39d94e update contributors[no ci] 2024-08-03 01:35:15 +00:00
Mingzhuo Yin c3caf94a7e chore: missing gpu info line break in theme-angel-kanade (#403) 2024-08-03 09:34:57 +08:00
naiba 322132c26b feat: 在面板重启时通知agent更新信息 2024-08-02 19:41:39 +08:00
naiba c92de5e2b9 chore: typo 2024-07-28 23:39:43 +08:00
UUBulb 118931f1aa chore: wrong name (#399) 2024-07-28 23:17:25 +08:00
UUBulb cb7e488ee6 ci: only keep the latest release (#398) 2024-07-28 23:08:16 +08:00
naiba 4ce4d85a3e improve: Handle case when host is not found in LookupGeoIP 2024-07-28 15:11:50 +08:00
naiba acd5d54886 fix: CountryCode 被冲掉 2024-07-28 14:39:14 +08:00
UUBulbandnaiba 2eb8417b24 feat: use embed geoip database (#396)
* feat: use embed geoip database

* mention ipinfo

* only read once

* comments

comments

* chore: installer version

---------

Co-authored-by: naiba <hi@nai.ba>
2024-07-28 13:59:58 +08:00
UUBulb 4eb21c051a api: add DisPlayIndex info (#397) 2024-07-28 13:43:28 +08:00
nap0o 388fbf681b 🎉 通过自定义代码实现server-status主题深色模式半透明样式的前置准备 (#395) 2024-07-24 23:15:17 +08:00
github-actions[bot] 4c0fcee399 update contributors[no ci] 2024-07-23 10:27:07 +00:00
MadDogOwner 72f7b9261c installer: improve region detection reliability using multiple APIs (#394) 2024-07-23 18:26:45 +08:00
github-actions[bot] c328b36273 update contributors[no ci] 2024-07-19 22:49:08 +00:00
UUBulb 208c51f5c8 installer: use multiple geoip api to determine location (#393) 2024-07-20 06:48:53 +08:00
Spetrum c8f61983c1 Switch IP checking service to ip-api.com (#391) 2024-07-17 21:21:04 +08:00
UUBulb eed522f30e fix: concurrent write to single WebSocket connection (#392) 2024-07-17 10:06:23 +08:00
naiba 02e87d9b4d fix: dashboard standalon-alone install 2024-07-15 16:01:24 +08:00
naiba 34d467b69a feat: fetch version from api for dashboard standalone install 2024-07-14 22:52:53 +08:00
naiba 78c76884e7 fix: workflow branch name 2024-07-14 19:52:04 +08:00
naiba 1e704d38ad ✨ 内置HTTP内网穿透 2024-07-14 19:41:50 +08:00
naiba b6cf09230a fix: branch name 2024-07-14 14:46:08 +08:00
github-actions[bot] a9b035e917 update contributors[no ci] 2024-07-14 06:38:29 +00:00
UUBulb 380568795a ci: sync release to gitee (#390) 2024-07-14 14:38:12 +08:00
naiba db2f65fead fix: loop delay 2024-07-14 13:04:35 +08:00
naiba 2a35a6f0e5 feat: 去除 webTerminal 的 websocket 依赖 2024-07-14 12:47:36 +08:00
UUBulb b7a94a0474 installer: restore the China mirror of agent (#389) 2024-07-14 07:34:45 +08:00
naiba 9d023f4f8b chore: go 1.21.x 2024-07-13 13:11:40 +08:00
naiba 6c1f82dccb fix: build 2024-07-13 13:00:48 +08:00
github-actions[bot] ceba00c507 update contributors[no ci] 2024-07-13 04:52:14 +00:00
Tao Chen 3364ab9892 添加OIDC支持 (#387)
* add general OIDC

* use "github.com/coreos/go-oidc/v3/oidc" to simplify oidc config

* fix: check if https by X-Forwarded-Proto

* recovery config.yaml
2024-07-13 12:51:59 +08:00
nap0o 226766aa7e 🐛 fixbug (#388)
* 修复bug:在server-status主题无法切换

* 替换哪吒面板后台cdn提供商为unpkg

* 替换哪吒面板后台cdn提供商为unpkg

* 加快Agent详情下拉展示速度
2024-07-12 22:29:11 +08:00
github-actions[bot] 876c50ec02 update contributors[no ci] 2024-07-10 15:14:11 +00:00
nap0o 3a9d24fbe7 ServerStatus主题优化 (#386)
* ServerStatus主题优化
1.更新世界地图基础geo文件,中国部分包含南海10段线,藏南部分划线更准
geo在大佬仓库https://github.com/Surbowl/world-geo-json-zh的基础上加工
2.世界地图可以在后台设置自定义代码切换是否包含南极洲,中国港澳台是否单独统计vps数
```<script>
  localStorage.setItem('countryMapGeoFile', 'nezha.world.geo.json'); //默认
  // localStorage.setItem('countryMapGeoFile', 'nezha.world.plus.geo.json'); //单独绘制香港,澳门,台湾
  // localStorage.setItem('countryMapGeoFile', 'nezha.world.antarctica.geo.json'); //有南极洲
  // localStorage.setItem('countryMapGeoFile', 'nezha.world.plus.antarctica.geo.json'); //有南极洲,单独绘制香港,澳门,台湾
</script>
```
3. mixin.js文件删除冗余代码
4. 首页agent下拉详情,控制网络折线图tooltip数字最多显示小数点后两位

* 修正geo文件路径

* 修复世界地图中国港澳台单独绘制时,vps数量计算逻辑

* 删除header中的无用的地图基础数据引用

* 增加温度控制cpu型号识别
2024-07-10 23:13:53 +08:00
UUBulb a80b40c2aa ddns: improve performance (#385) 2024-07-07 10:19:58 +08:00
UUBulb 7249948c74 fix: timestamp conversion (#383) 2024-07-04 14:32:19 +08:00
UUBulb 2d42e32d8a feat: notifications support for GPU & Temperature (#381) 2024-07-03 12:34:45 +08:00
nap0o a3bf926606 优化server-status主题服务页 (#382) 2024-07-02 22:11:20 +08:00
github-actions[bot] 098635d10e update contributors[no ci] 2024-06-28 02:32:48 +00:00
Kris 92c1bda452 Update README.md (#380) 2024-06-28 10:32:31 +08:00
github-actions[bot] 356f1b8674 update contributors[no ci] 2024-06-25 15:00:50 +00:00
UUBulb 209fb96a84 fix: EUID is a readonly variable on some shells (#379) 2024-06-25 23:00:30 +08:00
nap0o bdc5ff20d0 优化server-status主题首页网络图表 (#378) 2024-06-25 22:16:32 +08:00
UUBulb 417f314f2f refactor: make the installer script POSIX compliant (#376) 2024-06-25 22:15:49 +08:00
dependabot[bot] 8cd9c89844 Bump github.com/hashicorp/go-retryablehttp from 0.7.2 to 0.7.7 (#377) 2024-06-25 22:11:17 +08:00
nap0o 5a1df00488 优化gpu前端展示代码 (#375) 2024-06-23 22:20:04 +08:00
naiba 3596e443fa refactor: imporve code style 2024-06-23 16:15:08 +08:00
github-actions[bot] c5621b1392 update contributors[no ci] 2024-06-23 07:59:19 +00:00
UUBulb 2bae80e1c4 Temporary fix for DDNSDomain leaks (#374) 2024-06-23 15:59:03 +08:00
UUBulb b36b40c883 feat: Add GPU inspection support (#373) 2024-06-23 10:27:33 +08:00
github-actions[bot] a2559159d4 update contributors[no ci] 2024-06-22 14:52:43 +00:00
UUBulb bee32f3713 Hide DDNS domain info for guests (#371) 2024-06-22 22:52:29 +08:00
github-actions[bot] fa97728ec5 update contributors[no ci] 2024-06-22 14:51:57 +00:00
nap0o 0d3e6981ef default和serverstatus主题温度展示相关 (#372) 2024-06-22 22:51:42 +08:00
github-actions[bot] c35fc2ac84 update contributors[no ci] 2024-06-16 10:13:06 +00:00
UUBulb 6ca9b0bdbf Add option to reduct temperature information (#369) 2024-06-16 18:12:45 +08:00
UUBulb 8c909dfb7b chore: fix some typos (#370) 2024-06-16 18:04:27 +08:00
github-actions[bot] 24686a6715 update contributors[no ci] 2024-06-15 01:43:49 +00:00
nap0o ac0b43bdaa 修复一些bug (#367)
* bug修复:
1. serverstatus主题agent详情不显示内存信息
2. windows系统icon错误
小优化
1. echarts绘制世界地图所需的world.js坐标信息从本地加载切换为从cdn加载
2. font-logos切换为0.17.0版本,使得图标间距更合适

* 优化agent离线保留信息逻辑,从判断“实时在线”调整为判断“曾经在线”。

* 优化agent离线保留信息逻辑,从判断“实时在线”调整为判断“曾经在线”。
2024-06-15 09:43:30 +08:00
github-actions[bot] ee89d4af43 update contributors[no ci] 2024-06-14 10:03:18 +00:00
nap0o 5413b16f9d 优化default和server status主题 (#363) 2024-06-14 18:02:55 +08:00
UUBulb 22242e967e ci: using time/tzdata package to provide zoneinfo (#365) 2024-06-14 10:24:58 +08:00
github-actions[bot] 68a086632f update contributors[no ci] 2024-06-01 11:12:04 +00:00
Leon 3a9e8e6181 Add support for sensor temperature in host state (#359)
* feat: Add support for sensor temperature in host state

* chore: Update file permissions for saving config data
2024-06-01 19:11:48 +08:00
github-actions[bot] 14fcf63999 update contributors[no ci] 2024-05-31 14:05:21 +00:00
Coming c7bdd3181c Update GPG key for libsepol (#360) 2024-05-31 22:05:05 +08:00
github-actions[bot] d260aed180 update contributors[no ci] 2024-05-21 15:12:02 +00:00
UUBulb 4ba374d80c Use agent's builtin support for services instead (#357) 2024-05-21 23:11:43 +08:00
github-actions[bot] 50286986cc update contributors[no ci] 2024-05-19 14:35:11 +00:00
eya46 fc2fdf0554 删除多余空格 (#358) 2024-05-19 22:34:57 +08:00
nap0o ae74463f29 Update service.html (#356) 2024-05-07 09:09:53 +08:00
github-actions[bot] 2c2499f085 update contributors[no ci] 2024-05-05 12:13:15 +00:00
UUBulb c9d33db20b chore: fix a typo (#355) 2024-05-05 20:12:54 +08:00
Akkia c92e4999bc ✨ 支持cloudflare access OIDC认证 (#354) 2024-05-03 08:47:53 +08:00
UUBulb 3e51bce224 Add DDNS Profiles, use publicsuffixlist domain parser (#350)
* Add DDNS Profiles, use publicsuffixlist domain parser

* Add Tencent Cloud DNS Provider

* Restore validate DDNS provider function

* chore: fmt & upgrade dependencies
2024-04-27 13:36:36 +08:00
naiba 54eebc3efd daocloud github 镜像已经被打废了 2024-04-18 21:42:04 +08:00
github-actions[bot] 2720a690a6 update contributors[no ci] 2024-04-13 03:15:05 +00:00
Nanjing Hopefun Network Technology Co. Ltd 23c9b47772 Update install.sh (#347)
修改自动安装超时时间-T 10为-T 60,10秒超时在wget那一步经常连接探针下载失败。
2024-04-13 11:14:46 +08:00
github-actions[bot] a5b80f84e7 update contributors[no ci] 2024-04-09 12:43:54 +00:00
nap0o 0d7cc0fc99 修复ServerStatus主题Bug (#346) 2024-04-09 20:43:35 +08:00
github-actions[bot] 0e4505b7e6 update contributors[no ci] 2024-04-08 14:31:45 +00:00
nap0o a899f4f3e8 ServerStatus主题首页增加服务器世界分布图功能 (#344) 2024-04-08 22:31:28 +08:00
lvyaoting 5295c4ab56 chore: fix some comments (#345) 2024-04-08 22:30:39 +08:00
naiba d9776e21ce Update Go version to 1.20.x in GitHub workflows 2024-04-06 14:25:29 +08:00
naiba 4497363b08 Merge branch 'master' of github.com:naiba/nezha 2024-04-06 11:51:13 +08:00
naiba 3c451ea51b ✅ update ci 2024-04-06 11:51:08 +08:00
github-actions[bot] 37c62cda7b update contributors[no ci] 2024-04-06 03:49:43 +00:00
naiba c6e61545a2 ⬆️ Update Go version to 1.20 and update dependencies 2024-04-06 11:49:20 +08:00
naiba d471472565 ⬆️ Update dependencies 2024-03-30 11:15:26 +08:00
zhucaidan d3f2fd4a0e Optimize translation (#343) 2024-03-30 11:12:00 +08:00
github-actions[bot] 8ad57866b7 update contributors[no ci] 2024-03-30 03:08:46 +00:00
UUBulb f0b7f46b1d fix installer bugs & improve (#341)
* fix installer bugs && improve

* Update install_en.sh
2024-03-30 11:08:29 +08:00
github-actions[bot] 53c7dde643 update contributors[no ci] 2024-03-30 03:01:11 +00:00
UUBulb c1db52dc4a ddns: Add ability to update IPv4 or IPv6 only (#342)
* ddns: Add ability to resolve IPv4 or IPv6 only

* Fix bugs

* cdn cache
2024-03-30 11:00:55 +08:00
github-actions[bot] 44d62cefe6 update contributors[no ci] 2024-03-24 14:46:54 +00:00
naiba a753c57f41 Merge branch 'xykt-master' 2024-03-24 22:46:35 +08:00
naiba 0f467d1bd6 Remove redundant code for MaxTCPPingValue initialization 2024-03-24 22:45:56 +08:00
xykt c7529fc06d Update network.html 2024-03-24 19:26:51 +08:00
xykt 854e371bbb Update network.html 2024-03-24 19:21:32 +08:00
xykt 5fc689885e Update network.html 2024-03-24 19:21:07 +08:00
xykt 844ea64458 Update network.html 2024-03-24 19:20:34 +08:00
xykt aafd54e9be Update network.html 2024-03-24 19:20:01 +08:00
xykt beacacdb22 Merge branch 'naiba:master' into master 2024-03-24 19:13:03 +08:00
github-actions[bot] a1db436924 update contributors[no ci] 2024-03-24 08:13:23 +00:00
naiba 40c530b571 🐛 revert shit 2024-03-24 16:12:51 +08:00
xykt f6d7f3f69e Update network.html 2024-03-24 09:24:23 +08:00
xykt 2b739f3c20 Update network.html 2024-03-24 09:22:13 +08:00
xykt 984b32c051 Update network.html 2024-03-24 09:20:53 +08:00
xykt ece81e7ac6 Update network.html 2024-03-24 09:16:41 +08:00
xykt 370af67748 Update network.html
美化了三网延迟界面:
1. 将Marker缩小降低曲线遮挡
2. 设置为曲线对应的颜色使其更加直观
3. 设置Marker透明度降低对画面的影响
4. 将延迟极小值的Marker倒置显示以快速区别极大值
5. 将超过MaxTCPPingValue的点删除
6. 显示丢包分布(细线),以及服务器断流区间(粗线)
2024-03-24 01:04:03 +08:00
xykt 00054e7769 Merge branch 'naiba:master' into master 2024-03-23 13:25:37 +08:00
naiba 58097eef63 Update dependencies and fix test assertions 2024-03-23 13:00:47 +08:00
HsukqiLee 9b56f0a241 网络监控页面相关优化 (#333)
* Update network.html

* Update network.html

* Update network.html
2024-03-23 12:40:38 +08:00
github-actions[bot] 0c437f006a update contributors[no ci] 2024-03-23 04:19:22 +00:00
dependabot[bot] 360125d641 Bump google.golang.org/protobuf from 1.31.0 to 1.33.0 (#332)
Bumps google.golang.org/protobuf from 1.31.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-23 12:19:03 +08:00
xykt 8a4f646588 Merge branch 'naiba:master' into master 2024-03-04 16:07:13 +08:00
nap0o e9befd8e25 主题切换相关优化 (#331)
* angel-kanade模版增加主题切换功能

* Add files via upload

* 调整legend图例间距

* 渲染方式调整会canvas

* Update home.html

* Add files via upload

* Add files via upload

* Update footer.html

* Add files via upload

* Add files via upload

* 消除echarts折线上无效的0值

* 消除echarts折线的0值,替换为平均值

* 消除echarts折线图上的0值

* 修复MaxTCPPingValue取值错误
2024-03-04 09:36:17 +08:00
github-actions[bot] 16699aa2b0 update contributors[no ci] 2024-03-02 07:20:48 +00:00
nap0o a7f461f109 修复echarts不显示bug (#330)
* angel-kanade模版增加主题切换功能

* Add files via upload

* 调整legend图例间距

* 渲染方式调整会canvas

* Update home.html
2024-03-02 15:20:32 +08:00
naiba ee265741b2 kkgithub 已失效 2024-02-28 23:30:59 +08:00
naiba fcb98c50f9 后台关闭切换主题功能 2024-02-27 21:05:39 +08:00
github-actions[bot] ce7e2197dd update contributors[no ci] 2024-02-27 12:43:12 +00:00
nap0o 51b7acdae0 angel-kanade模版增加主题切换功能 (#329) 2024-02-27 20:42:56 +08:00
github-actions[bot] ca3135af07 update contributors[no ci] 2024-02-26 12:59:18 +00:00
naiba 8d3a7c70ea 💄 限制最大宽度 2024-02-26 20:58:50 +08:00
Darc Z 357155cb90 Fix DDNS bugs and split up ddns module (#326)
* fix: webhook retry logic
fix: adjust record type based on ipv4 ipv6
refract: move ddns providers to a new module

* refract: move ddns module to pkg/
2024-02-26 11:49:28 +08:00
naiba dea39c7b3b 🐛 查看密码逻辑修复 2024-02-26 10:11:02 +08:00
github-actions[bot] 6202d91b91 update contributors[no ci] 2024-02-26 01:31:35 +00:00
nap0o 57139f733d fix bug (#328)
* fix bugs

1. 修复default主题mixin.js文件丢失
2. 修复主题默认值为后台设置值
3. default主题样式优化

* 修复前台切换 theme-custom 不生效
2024-02-26 09:31:16 +08:00
github-actions[bot] e47f98f5c9 update contributors[no ci] 2024-02-25 15:35:34 +00:00
naiba 3e1f9457d0 Update mixin.js file path in header.html 2024-02-25 23:35:06 +08:00
nap0o b32990cf28 优化default和serverstatus主题模版 (#327)
1.优化default主题模版
2.优化serverstatus主题模版
2024-02-25 23:16:57 +08:00
naiba 736d266607 🚸 校验自定义主题、将 Theme 列表插入所有页面 2024-02-25 14:15:19 +08:00
naiba d679efc70a 🔒️ 增强 ping 历史 API 鉴权 2024-02-24 23:21:33 +08:00
naiba 5ad9cc8081 ✨ 前端切换模板 2024-02-24 22:28:07 +08:00
github-actions[bot] aaf43859b5 update contributors[no ci] 2024-02-24 13:10:48 +00:00
Darc Z f9c0bb2240 Add DDNS support (#324)
* feat: add ddns updater framework

Note: no functionality implemented yet

* feat: add webhook ddns provider

* feat: update dashboard template

* fix: check nil and cron task string

* fix: webhook string formated with unexcepted param

* fix: webhook header split error

* feat: cloudflare ddns provider

* refract: move ddns update trigger into ReportSystemInfo

* lang: update other languages text

* fix: clear codes and logics

* fix: move update ddns to goroutine to avoid blocking

* fix: clear unused codes

* fix: update timestamp to prevent cache
2024-02-24 21:10:27 +08:00
github-actions[bot] 0e31b5fba9 update contributors[no ci] 2024-02-23 14:55:59 +00:00
1ridic f6536418cc 优化安装逻辑 (#325)
* Optimize install process

If `getenforce` is not exist, then do not execute
it. This fixes the `command not found` error on
Arch Linux systems without SELinux installed.

* bump installer version
2024-02-23 22:55:42 +08:00
github-actions[bot] 29168b17db update contributors[no ci] 2024-02-21 12:19:50 +00:00
凌 d48be0c29f Arch 修复 nezha-agent 主目录遗留问题 (#323)
* Arch 修复 nezha-agent 主目录遗留问题

使用useradd -m创建的用户会产生主目录文件夹,使用userdel需要添加-r参数

* Update install_en.sh

* bump installer version
2024-02-21 20:19:34 +08:00
github-actions[bot] 2dc7065a45 update contributors[no ci] 2024-02-19 13:00:09 +00:00
HsukqiLee f1d7873fc5 增加了网络页面对 Neko Meui 主题的支持 (#322)
* 修改 Neko-mdui 主题的菜单栏

增加了“网络”选项卡

* 增加 Neko-mdui 主题的网络页面
2024-02-19 20:59:49 +08:00
xykt 356076e449 对历史延迟图表进行了相关修复/美化工作 (#321)
* Update network.html

修复以下问题:
- 延迟默认最高300ms,超过后默认抹平
- 曲线上有很多ping值为0的无效散点,导致毛刺很多,干扰效果
- 图标的y轴比例失调,上方大片留白,大大降低了有效显示区域
- 默认只显示最近不到半小时左右的延迟表现,想看全天需要拖动,影响效果
- 曲线不显示极大极小值,不够直观

* Update config.go

修复了以下问题:
- 延迟默认最高300ms,超过后默认抹平
- 曲线上有很多ping值为0的无效散点,导致毛刺很多,干扰效果
- 图标的y轴比例失调,上方大片留白,大大降低了有效显示区域
- 默认只显示最近不到半小时左右的延迟表现,想看全天需要拖动,影响效果
- 曲线不显示极大极小值,不够直观
2024-02-19 13:52:43 +08:00
xykt 421e8df55e Update config.go
修复了以下问题:
- 延迟默认最高300ms,超过后默认抹平
- 曲线上有很多ping值为0的无效散点,导致毛刺很多,干扰效果
- 图标的y轴比例失调,上方大片留白,大大降低了有效显示区域
- 默认只显示最近不到半小时左右的延迟表现,想看全天需要拖动,影响效果
- 曲线不显示极大极小值,不够直观
2024-02-19 01:17:42 +08:00
xykt 72cd680905 Update network.html
修复以下问题:
- 延迟默认最高300ms,超过后默认抹平
- 曲线上有很多ping值为0的无效散点,导致毛刺很多,干扰效果
- 图标的y轴比例失调,上方大片留白,大大降低了有效显示区域
- 默认只显示最近不到半小时左右的延迟表现,想看全天需要拖动,影响效果
- 曲线不显示极大极小值,不够直观
2024-02-19 01:14:26 +08:00
github-actions[bot] 2726f49a06 update contributors[no ci] 2024-02-18 12:00:59 +00:00
Ko no dio 55d483d409 feat: add index on monitor history (#320) 2024-02-18 20:00:36 +08:00
github-actions[bot] 4944df9c77 update contributors[no ci] 2024-02-14 05:12:49 +00:00
Ko no dioandLvGJ e0f1c1d729 fix: revert service html and block service in backend (#318)
Co-authored-by: LvGJ <lvgj1998@gmail.com>
2024-02-14 13:12:24 +08:00
github-actions[bot] 16a150470c update contributors[no ci] 2024-02-13 15:09:58 +00:00
Ko no dioandLvGJ 70ed0c819f fix: add toggle to service (#317)
* fix: short nework word

* fix: add toggle on service

* fix: delete console log update main.js version

---------

Co-authored-by: LvGJ <lvgj1998@gmail.com>
2024-02-13 23:09:40 +08:00
github-actions[bot] a925fbfb43 update contributors[no ci] 2024-02-12 06:16:26 +00:00
Ko no dioandLvGJ 7ab36d4e11 feat: add network monitor hitory (#316) · 三网ping
* feat: add network monitor hitory

* fix: revert proto change and add indexStore

* fix: update monitor delete unuse monitor history

* fix: delete unuse monitor type

---------

Co-authored-by: LvGJ <lvgj1998@gmail.com>
2024-02-12 14:16:04 +08:00
naiba 7b4671fe83 ♻️ 更换可用镜像地址 2024-02-09 20:59:22 +08:00
github-actions[bot] fa00932e2e update contributors[no ci] 2024-01-18 05:35:21 +00:00
黑歌 8e64524978 replace daocloud with fgit (#315)
* Update add custom mirror

* Update add custom mirror

* bump install version
2024-01-18 13:34:58 +08:00
github-actions[bot] 457547cc2e update contributors[no ci] 2023-12-26 02:26:27 +00:00
Crazy 9c91bf0112 add supervise daemon for alpine (#313)
alpine在自动更新agent以后不会自动重启agent,导致批量掉线,添加守护进程以后问题解决
2023-12-26 10:26:11 +08:00
dependabot[bot] 8fdf5a57db Bump golang.org/x/crypto from 0.15.0 to 0.17.0 (#312)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.15.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.15.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-19 09:43:33 +08:00
naiba 51360bf98c replace statifile cdn with bytedance cdn 2023-12-11 21:53:56 +08:00
github-actions[bot] ad548c5910 update contributors[no ci] 2023-12-08 13:17:02 +00:00
spiritlhl 79aebb1f87 更新判断面板是否是通过docker安装的逻辑 (#311)
* 更新判断面板是否是通过docker安装的逻辑

* Updated logic for determining whether a panel is installed via docker or not
2023-12-08 21:16:36 +08:00
naiba 341d0aeb47 Add comment about missing riscv64 image in latest Alpine image 2023-12-08 09:55:17 +08:00
github-actions[bot] a2c78b5743 update contributors[no ci] 2023-12-07 13:59:01 +00:00
nap0oandSuperHsiao 95b1e6c69b improve server-status theme (#310)
* Add files via upload

* Add files via upload

* Add files via upload

* improve theme-server-status

1.前台分组展示agent
2.一些小优化

---------

Co-authored-by: SuperHsiao <superhsiao@4indesign.com>
2023-12-07 21:58:42 +08:00
github-actions[bot] 910710c5bf update contributors[no ci] 2023-12-07 13:41:51 +00:00
Mmx 9789f2eba6 Improve: use alpine as dockerfile base image (#309)
* build: use alpine as base image

* build: use alpine edge instead of latest
2023-12-07 21:41:26 +08:00
github-actions[bot] 8aa5f674ef update contributors[no ci] 2023-12-06 14:42:42 +00:00
Applexad a74024eda7 installer: fix docker compose command determination (#307) 2023-12-06 22:42:22 +08:00
卖女孩的小火柴 82d768e4ee Fix select_version (#306) 2023-12-06 21:35:24 +08:00
github-actions[bot] b50d764b69 update contributors[no ci] 2023-12-06 01:35:54 +00:00
Mmx 256ca2465b fix: field name "VerifySSL" to "SkipVerifySSL" in Transport config (#305) 2023-12-06 09:35:38 +08:00
github-actions[bot] c673a8fbb3 update contributors[no ci] 2023-12-05 15:23:19 +00:00
Mmx 9df97e2bdb Fix Coroutine Leaks and Proxy Handling in http.Client (#304)
* fix: builtin variable conflict

* feat: add pkg/utils/http HttpClientSkipTlsVerify HttpClient

* fix: realtime create http Transport

* fix: http keepalive connection may not reusable

* feat: allow http request use proxy from environment
2023-12-05 23:22:25 +08:00
naiba 0d24b232d8 Update installer version to v0.15.2 2023-12-04 10:23:56 +08:00
naiba 37bf5f6282 Update destination repository URL in sync workflow 2023-12-04 10:21:13 +08:00
github-actions[bot] 53fad16020 update contributors[no ci] 2023-11-30 05:16:55 +00:00
Applexad 10ede65390 Exit after installing agent (#303)
If entered with install_agent argument, variable IS_DOCKER_NEZHA will not be set, thus leading select menu to appear, which could be annoying.
2023-11-30 13:16:39 +08:00
github-actions[bot] a2a23f586d update contributors[no ci] 2023-11-30 02:49:24 +00:00
Applexad d1e4b76328 Fix various bugs in fresh installation (#302) 2023-11-30 10:49:06 +08:00
naiba c870114ccc Update GitHub Actions workflow to include specific paths 2023-11-30 09:52:24 +08:00
github-actions[bot] 865f8b52d9 update contributors[no ci] 2023-11-30 01:39:22 +00:00
Applexad 6b8f549bf4 Add standalone installation method & OpenRC support for nezha-agent (#300)
* Add standalone installation method & OpenRC support for nezha-agent

* Add missing translations & more precise query

* bump installer version
2023-11-30 09:39:00 +08:00
卖女孩的小火柴 52511b71c4 Fix: get.daocloud.io 503 (#301) 2023-11-30 09:31:39 +08:00
naiba 040270e3ab Export custom static folder 2023-11-29 17:13:45 +08:00
naiba 9bcdb40ecd 📦 static link 2023-11-29 15:45:32 +08:00
naiba 0380ae7f97 Update badges in README 2023-11-29 09:44:47 +08:00
naiba 48dfd0a94b 🐛 fix third-party template loading 2023-11-29 09:42:51 +08:00
naiba ae465723eb Update release workflow to extract tag name 2023-11-28 23:31:35 +08:00
naiba 05eb0e9bf2 Update release and test workflows 2023-11-28 23:18:29 +08:00
naiba 538501b0e5 Update release workflow 2023-11-28 23:08:50 +08:00
naiba 746f5a8b23 ✨ v0.15.13 generate release 2023-11-28 23:01:37 +08:00
naiba b379ae03e5 🚸 Add location flag to server status page 2023-11-26 22:29:55 +08:00
naiba 513269ef71 💄 improve server-status home table
responsiveness
2023-11-22 12:45:54 +08:00
naiba 1c81e23408 💄 Update container max-width in service.html 2023-11-22 10:22:56 +08:00
naiba 7ec0660054 💄 improve serverstatus progressbar 2023-11-22 10:08:48 +08:00
naiba d8d172c9af 🩹 fix go template syntax 2023-11-21 10:31:19 +08:00
naiba 6603415839 🚸 [v0.15.11] improve translation and serverstats theme 2023-11-21 09:59:14 +08:00
github-actions[bot] 08ce9b0e03 update contributors[no ci] 2023-11-19 07:18:21 +00:00
Kris e34d684314 优化繁体中文、英语、西班牙语语言文件 (#299)
* 优化中文语言文件

* 优化繁体中文、英语、西班牙语语言文件
2023-11-19 15:18:04 +08:00
Kris 06da70dda1 优化中文语言文件 (#298) 2023-11-18 09:39:38 +08:00
github-actions[bot] 40da76129b update contributors[no ci] 2023-11-13 08:10:27 +00:00
fscarmen 11ef263350 [agent] Alpine 系统增加 SSL/TLS加密 (--tls) 支持。 (#297) 2023-11-13 16:10:09 +08:00
naiba 230ceb53ba 💄 put back semantic.min.css 2023-11-09 21:05:13 +08:00
naiba 492d485101 💄 improve server-status theme 2023-11-09 17:31:26 +08:00
naiba 7b97b35818 fix codeql 2023-11-08 13:23:31 +08:00
naiba 1274622698 💄 优化 server status 主题 2023-11-08 13:17:42 +08:00
naiba 9318af8060 💄 优化 server-status 主题服务页流量统计 2023-11-07 22:56:59 +08:00
naiba 0e00615050 💄 优化 server-status 主题 2023-11-07 22:15:13 +08:00
github-actions[bot] b0be4117e7 update contributors[no ci] 2023-11-07 05:46:51 +00:00
unclezsandnaiba 0f8bfc21ad ✨ feat: add server-status theme (#295)
* ✨ feat: add server-status theme

* add `ServerStatus` theme to README

---------

Co-authored-by: naiba <hi@nai.ba>
2023-11-07 13:46:28 +08:00
卖女孩的小火柴 5ab46b9ebb Feat: Add some parameters (#294) 2023-11-02 21:06:34 +08:00
naiba 63a7dde4f4 🌐 fix link 2023-10-30 09:54:58 +08:00
naiba 1d9235d126 update README.md 2023-10-27 12:06:52 +08:00
naiba 669c2a64ee update install script URL 2023-10-26 23:32:24 +08:00
naiba 119df9e980 sync to jihulab 2023-10-26 23:29:20 +08:00
naiba 85bfa4dc63 fix theme-angel-kanade byte format 2023-10-22 20:12:38 +08:00
naiba f31516122c fix dashboard build 2023-10-21 22:35:12 +08:00
naiba 6d3caf6eb9 fix dashboard build 2023-10-21 22:31:29 +08:00
naiba f62f68fbeb fix gosec (3 2023-10-21 22:21:21 +08:00
naiba 30db2f2441 fix gosec 2023-10-21 22:11:55 +08:00
naiba b57ced1329 update workflows 2023-10-21 18:27:25 +08:00
naiba 78cb02b886 fix gosec 2023-10-21 18:13:38 +08:00
naiba 106ebc6db3 fix theme angel kanade 2023-10-21 18:05:56 +08:00
github-actions[bot] 811fa94f15 update contributors[no ci] 2023-10-12 01:51:45 +00:00
dependabot[bot] ae82f0c718 Bump golang.org/x/net from 0.14.0 to 0.17.0 (#293)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-12 09:51:31 +08:00
naiba 668453eed0 v0.15.2 消息模板新增 #DATETIME# 占位符 2023-09-02 00:06:40 +08:00
黑歌 7e0fa78d6a Update en-US.toml (#292) 2023-08-28 22:15:14 +08:00
naiba 986eefea5a Update README.md 2023-07-04 23:10:46 +08:00
naiba 25e443bbd4 Update README.md 2023-06-30 12:08:14 +08:00
naiba a65365fce3 bump agent version [no ci] 2023-06-30 10:06:59 +08:00
naiba 2e1286ff29 bump agent version [no ci] 2023-06-30 00:40:23 +08:00
naibaandAkkiaS7 49abd37048 Merge pull request #291 from AkkiaS7/fix-service-recover-notification
服务状态变更时清除静音缓存

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2023-06-19 11:02:13 +08:00
Akkia 09a7d77bea 不忽略首次请求就出错的情况 2023-06-19 05:13:05 +08:00
Akkia e6d46f11fe 始终发送ip变更通知+优化ssl与延迟通知静音缓存规则 2023-06-19 05:02:20 +08:00
Akkia 91edca2202 服务状态变更时清除静音缓存 2023-06-18 04:02:41 +08:00
naiba db8d0af5be v0.15.1 批量删除服务器 bulk delete the servers 2023-06-14 00:23:47 +08:00
github-actions[bot] 2db1e38f71 update contributors[no ci] 2023-06-02 02:17:51 +00:00
naiba e3cd6e2a96 Merge pull request #290 from naiba/dependabot/go_modules/github.com/gin-gonic/gin-1.9.1
Bump github.com/gin-gonic/gin from 1.9.0 to 1.9.1
2023-06-02 10:17:25 +08:00
dependabot[bot] 262b690c52 Bump github.com/gin-gonic/gin from 1.9.0 to 1.9.1
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.9.0 to 1.9.1.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.9.0...v1.9.1)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-06-01 20:33:37 +00:00
naiba d7ffffcbf1 Update install.ps1 [no ci] 2023-05-11 09:39:38 +08:00
naiba 90469dfba7 clean: remove agent 💩 2023-05-11 00:13:11 +08:00
naiba ad32426ab0 [agent] splitting the agent into separate repositories 2023-05-11 00:08:54 +08:00
naiba 893145d1ae agent: rename repo 2023-05-10 23:34:36 +08:00
naiba 9639979b37 downgrade sqlite driver 2023-05-10 23:24:46 +08:00
naiba 2ef1e4275f ⬆️ upgrade dependencies 2023-05-10 00:34:04 +08:00
github-actions[bot] 4d44abb2b9 update contributors[no ci] 2023-05-08 04:51:34 +00:00
naiba fff9a36479 update contributors avatar size 2023-05-08 12:51:14 +08:00
github-actions[bot] 1b4383715d update contributors[no ci] 2023-05-08 04:49:07 +00:00
naiba 7cc07d9962 update contributors avatar size 2023-05-08 12:48:50 +08:00
naiba a92cee0da8 add contributors to readme 2023-05-08 12:45:32 +08:00
naiba 89d127bad0 Update README.md 2023-05-08 12:00:35 +08:00
naiba 8aba0516b3 upgrade protoc && protoc-gen-go 2023-04-26 11:16:36 +08:00
6058fd3027 ✨ dash v0.15.0 服务监控触发任务执行
Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
Co-authored-by: cantoblanco <116849421+cantoblanco@users.noreply.github.com>
2023-04-17 00:09:04 +08:00
naiba 86bf8a70eb Merge pull request #289 from cantoblanco/master [no ci]
新增文本的多语言翻译
2023-04-17 00:04:58 +08:00
Kris 5c7c99fd77 新增文本的多语言翻译 2023-04-16 17:58:02 +02:00
naiba e6bb2bf87c Merge pull request #288 from AkkiaS7/feat-service-trigger-task
Feat: 服务监控支持触发任务执行
2023-04-16 23:27:09 +08:00
Akkia b2013594eb 🪄mutelabel生成 2023-04-16 22:47:12 +08:00
Akkia a0d1175c56 服务监控支持触发任务执行 2023-04-15 19:04:38 +08:00
Akkia d87d03f3c0 报警任务状态改变时 清除另一个状态的muteLabel 2023-04-15 19:04:04 +08:00
naiba 3d1c8e9512 ⬆️ upgrade dependencies 2023-04-12 22:32:25 +08:00
naiba a82dc397b6 dash v0.14.13 fix home.html 2023-04-09 11:18:44 +08:00
naiba 778bd4a32c Merge pull request #284 from dysf888/patch-3
Update home.html
2023-04-09 10:57:36 +08:00
naiba d7d88e695a Merge pull request #283 from dysf888/patch-2 [no ci]
Update zh-CN.toml
2023-04-09 10:57:19 +08:00
naiba 02548047f7 Merge pull request #285 from dysf888/patch-4 [no ci]
Update en-US.toml
2023-04-09 10:56:57 +08:00
naiba 3e003361ba Merge pull request #286 from dysf888/patch-5 [no ci]
Update zh-TW.toml
2023-04-09 10:56:38 +08:00
naiba 265b5ccda9 Merge pull request #287 from dysf888/patch-6 [no ci]
Update es-ES.toml
2023-04-09 10:56:20 +08:00
黑歌-共穷国际大中华区CFO 668825dcba Update es-ES.toml 2023-04-09 01:25:27 +08:00
黑歌-共穷国际大中华区CFO dcab539a43 Update zh-TW.toml 2023-04-09 01:24:15 +08:00
黑歌-共穷国际大中华区CFO d17c5531b7 Update en-US.toml 2023-04-09 01:22:09 +08:00
黑歌-共穷国际大中华区CFO f352f46790 Update home.html 2023-04-09 01:21:00 +08:00
黑歌-共穷国际大中华区CFO d1c36ac5a6 Update zh-CN.toml 2023-04-09 01:20:41 +08:00
naibaandadminsama 006ebec14b dash v0.14.12 new theme AngelKanade
Co-authored-by: adminsama <60880076+adminsama@users.noreply.github.com>
2023-04-09 00:28:57 +08:00
naiba d41b21cabc dash: v0.14.12 new theme AngelKanade 2023-04-09 00:28:21 +08:00
adminsama b70b793fb0 Adding an theme 2023-04-08 23:57:28 +08:00
naiba 52952d5b9c ⬆️ upgrade dependencies 2023-04-01 13:23:18 +08:00
naiba 51dbda0c7f Merge pull request #281 from cantoblanco/master
Update README.md
2023-03-31 09:35:01 +08:00
Kris a1a566128b Update README.md 2023-03-31 02:25:59 +02:00
naiba 30cd83564d 🚸 [agent] get geoip with UA 2023-03-30 17:17:06 +08:00
naibaandspiritLHLS 4a0f78deb4 Merge pull request #280 from spiritLHLS/master
效仿中文版shell给英文版shell增加中国IP判断

Co-authored-by: spiritLHLS <103393591+spiritLHLS@users.noreply.github.com>
2023-03-06 17:26:41 +08:00
spiritLHLS c8ab55f5e9 Update install_en.sh 2023-03-06 15:49:18 +08:00
spiritLHLS 21b5a4e009 Update install_en.sh 2023-03-06 10:21:15 +08:00
spiritLHLS a419df90d7 Update install_en.sh 2023-03-05 22:09:24 +08:00
spiritLHLS 3ba2b953a1 Update install_en.sh 2023-03-05 22:08:41 +08:00
naibaandErope 07d90e4ab5 Merge pull request #279 from Erope/master
Fix: fix icmp ping & fix agent action build

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2023-03-01 23:43:21 +08:00
naiba 1f039aa575 refine 2023-03-01 23:42:39 +08:00
卖女孩的小火柴 720e5052f8 use goreleaser-cross:v1.19.2
see https://github.com/actions/checkout/issues/1169
2023-03-01 23:29:56 +08:00
卖女孩的小火柴 6ed14408f0 Fix icmp ping 2023-03-01 22:06:44 +08:00
naibaandcolour93 7c186a61ce Merge pull request #278 from colour93/patch-1
添加提醒方式页面placeholder少了两个逗号

Co-authored-by: colour93 <64313711+colour93@users.noreply.github.com>
2023-02-23 09:50:23 +08:00
玖叁 a6de633d18 添加提醒方式页面placeholder少了两个逗号
添加提醒方式页面placeholder少了两个逗号,图方便把placeholder拷出来发现的
2023-02-23 00:56:13 +08:00
naiba e809a428df Merge pull request #277 from naiba/dependabot/go_modules/golang.org/x/net-0.7.0
bump golang.org/x/net from 0.5.0 to 0.7.0
2023-02-20 23:52:23 +08:00
dependabot[bot] 4a2217af41 bump golang.org/x/net from 0.5.0 to 0.7.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.5.0 to 0.7.0.
- [Release notes](https://github.com/golang/net/releases)
- [Commits](https://github.com/golang/net/compare/v0.5.0...v0.7.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-18 04:05:19 +00:00
naiba d7f9f29412 Merge pull request #276 from cantoblanco/master
Update Readme
2023-02-16 23:49:46 +08:00
Kris e9e787e1f8 Update Readme 2023-02-16 13:35:49 +01:00
naiba 380914a19f remove release sync 2023-02-14 19:56:13 +08:00
naiba bea284ad25 fix gosec G307 2023-02-12 21:10:34 +08:00
naiba 9cd1e23cd9 Merge pull request #273 from Erope/master
Feat: Add jsdelivr purge step in workflow
2023-02-12 20:00:31 +08:00
卖女孩的小火柴 f3224199c8 Add jsdelivr purge step in workflow 2023-02-11 23:08:28 +08:00
naiba 96528b7574 Merge pull request #272 from AkkiaS7/master
update: 使用jsdelivr替代jihulab
2023-02-04 20:42:54 +08:00
Akkia 89dcacf528 update: 使用jsdelivr替代jihulab 2023-02-04 20:36:29 +08:00
naiba aaf6c41b77 🔖 dashboard v0.14.11 2023-02-03 17:38:14 +08:00
naiba fc13e19e5f Merge pull request #271 from rootmelo92118/patch-1
Create zh-TW.toml
2023-02-03 16:00:19 +08:00
naiba d6bd05e95f fix myip api 2023-02-03 15:33:00 +08:00
rootmelo92118 61c41a4f63 Update zh-TW.toml 2023-02-03 15:12:03 +08:00
rootmelo92118 aca55751c3 Update config.go 2023-02-03 13:12:14 +08:00
rootmelo92118 c055563055 Create zh-TW.toml 2023-02-03 13:09:04 +08:00
naiba c833d0d556 ✏️ fix typo 2023-02-02 23:06:02 +08:00
naiba 537f9b8bdd ⬆️ upgrade dependencies 2023-02-02 22:44:47 +08:00
naiba cbe21877ff ♿️ 支持未 root 的 Android 设备 close #270 2023-01-25 16:14:08 +08:00
naiba fe5060406c Merge pull request #269 from 1ridic/master
修正 Arch 的安装错误
2023-01-22 13:28:50 +08:00
iridiumR 8901590b8f 修复小错误
似乎并不需要在这里再次安装 git
2023-01-22 09:49:40 +08:00
iridiumR fe46cbd113 fix arch installation error for en script 2023-01-22 09:02:58 +08:00
iridiumR 0ef7de141d 修正 Arch 的安装错误
Arch 的官方源中不含有`yay`,安装脚本亦没有检查`base-devel`包和`git`包是否存在,正确方法是克隆仓库后使用`makepkg`安装`libselinux`
2023-01-21 21:22:05 +08:00
naiba e730f725da 🐛 fix panic when update the time zone 2022-12-29 00:41:11 +08:00
naiba 61c7d13d31 📝 update release workflow badge [no ci] 2022-12-25 23:07:50 +08:00
naiba 376f9a6097 Merge pull request #268 from AkkiaS7/master
fix: 部分环境下启动时间锁定在1970年的问题
2022-12-25 22:40:06 +08:00
Akkia 74772500a9 fix: 部分环境下启动时间锁定在1970年的问题 2022-12-25 21:26:34 +08:00
naibaandyuanweize c8a817c533 Merge pull request #267 from yuanweize/patch-1
Update service.html

Co-authored-by: yuanweize <30067203+yuanweize@users.noreply.github.com>
2022-12-24 22:34:01 +08:00
iYuanWeize 79faf58218 Update service.html
Add CycleTransferStats in theme-daynight/service.html
2022-12-24 12:41:56 +01:00
naiba 7e2afdb350 📝 update badge [no ci] 2022-12-16 23:48:11 +08:00
naiba 15f4816e1f 🔒️ more secure token generation 2022-12-16 23:34:14 +08:00
naiba 5a291cbc18 Merge pull request #264 from dysf888/patch-3 [no ci]
Fix fatal permission issue
2022-12-13 13:45:05 +08:00
naiba fba0200cf8 Merge pull request #265 from dysf888/patch-1 [no ci]
Fix fatal permission issue
2022-12-13 13:44:48 +08:00
黑歌-共穷国际大中华区CFO ce1c0835b1 Fix fatal permission issue
Fix non-arch system execution installation arch system related software errors
Fix the problem that the created user has login permissions, etc.
2022-12-12 22:50:36 +08:00
黑歌-共穷国际大中华区CFO d4e54521b9 Fix fatal permission issue
Fix non-arch system execution installation arch system related software errors
Fix the problem that the created user has login permissions, etc.
2022-12-12 22:36:18 +08:00
naiba 4a5b7ebfcd Revert "Merge pull request #262 from dysf888/patch-2 [no ci]"
This reverts commit c58af25414, reversing
changes made to 405f852c7d.
2022-12-11 14:57:23 +08:00
naiba 0604b2799d Revert "Merge pull request #263 from dysf888/patch-1 [no ci]"
This reverts commit 405f852c7d, reversing
changes made to 2f7fd589bf.
2022-12-11 14:57:09 +08:00
naiba c6cf33e073 Merge pull request #262 from dysf888/patch-2 [no ci]
更好的支持archlinux
2022-12-11 12:11:11 +08:00
naiba 44517ae05c Merge pull request #263 from dysf888/patch-1 [no ci]
Better support for archlinux
2022-12-11 12:10:44 +08:00
黑歌-共穷国际大中华区CFO 1d24a1093b Update install_en.sh 2022-12-10 18:05:10 +08:00
黑歌-共穷国际大中华区CFO dacac6bceb Better support for archlinux 2022-12-10 12:16:51 +08:00
黑歌-共穷国际大中华区CFO c672762918 更好的支持archlinux 2022-12-10 12:04:24 +08:00
naiba 5a8dd632f1 Merge pull request #260 from fscarmen/master [no ci]
Master
2022-11-13 12:34:38 +08:00
fscarmen 4db9c807ad Agent supports Alpine OS. 2022-11-13 03:53:01 +00:00
naiba 14bee038bc Merge pull request #259 from coreff/patch-2 [no ci]
Update install_en.sh
2022-11-08 11:06:11 +08:00
naiba f38b67d7f6 Merge pull request #258 from coreff/patch-1 [no ci]
Update install.sh
2022-11-08 11:05:41 +08:00
Core F bd79a6442a Update install_en.sh 2022-11-08 11:00:28 +08:00
Core F 5ddb7f5d58 Update install.sh 2022-11-08 10:55:58 +08:00
naiba d03c902bb8 🚀 dashboard v0.14.7 with agent v0.14.4 2022-11-01 21:46:02 +08:00
naiba 7c1ff53e2b Merge pull request #256 from AkkiaS7/master
fix: nameserver issue
2022-11-01 18:16:20 +08:00
Akkia 613cff1cb5 fix: nameserver issue 2022-11-01 18:02:46 +08:00
naiba 946ce73096 🚀 dashboard v0.14.6 with installer v0.11.0 2022-10-31 21:54:37 +08:00
naiba e561b10327 Merge pull request #255 from AkkiaS7/master
服务器备注支持换行显示
2022-10-24 20:38:45 +08:00
Akkia 6e5fadd121 服务器备注支持换行显示 2022-10-24 20:11:53 +08:00
naibaandqcgzxw dd98eb0e19 Merge pull request #254 from qcgzxw/bug
fix: 更新导致创建日期归零

Co-authored-by: qcgzxw <24518597+qcgzxw@users.noreply.github.com>
2022-10-24 12:14:11 +08:00
naiba 3f72d2e641 gorm: CreatedAt index 2022-10-24 04:12:51 +00:00
owen acd1c95ed6 fix: 更新导致创建日期归零 2022-10-23 23:53:24 +08:00
naiba 5e08c04595 ✏️ fix typo 2022-10-19 00:11:31 +08:00
naiba 21c7734b6e agent enable ipv6 connections stat 2022-10-18 15:40:01 +00:00
naiba d5255449cd 🚸 add tooltip for secret copy button 2022-10-16 23:30:06 +08:00
naiba a2550384c4 Merge pull request #252 from AkkiaS7/master
fix: Gitee login error
2022-10-14 08:48:27 +08:00
Akkia 280003eed5 fix: Gitee login error 2022-10-14 01:31:00 +08:00
naibaandysicing 5bdaca3f5f 🚀 dashboard v0.14.5 login with gitea & fix stats of service.
Co-authored-by: ysicing <8605565+ysicing@users.noreply.github.com>
2022-10-12 23:09:16 +08:00
naiba 360d30681e 🐛 fix service stats 2022-10-12 23:06:25 +08:00
naibaandysicing 3bc254c05c Merge pull request #251 from ysicing/master
feat(gitea):  oauth2 add gitea support

Co-authored-by: ysicing <8605565+ysicing@users.noreply.github.com>
2022-10-12 19:43:04 +08:00
ysicing bfe8b7e741 feat(oauth2): add gitea oauth2 support
add gitea oauth2 support

Signed-off-by: ysicing <i@ysicing.me>
2022-10-12 19:34:15 +08:00
naiba 9132ae02f3 Merge pull request #250 from hhhkkk520/master
Improved translation of texts
2022-10-01 22:12:31 +08:00
“hhhkkk520” e74df74170 Improved translation of texts 2022-10-01 14:26:32 +02:00
naiba 8d8bb26bbe 🚀 agent v0.14.1 2022-10-01 11:31:39 +08:00
naiba 41e8fe1a02 🚀 dashboard v0.14.4 hide the server from guests 2022-09-30 22:40:56 +08:00
naiba 598f599308 Merge pull request #249 from naiba/l10n_master [no ci]
New Crowdin updates
2022-09-30 22:36:36 +08:00
naiba 801f5f0983 New translations zh-CN.toml (English) 2022-09-30 22:36:02 +08:00
naiba 5ff58929fa New translations zh-CN.toml (Spanish) 2022-09-30 22:36:01 +08:00
naiba 8545e35288 update l10n [no ci] 2022-09-30 22:34:52 +08:00
naiba 1716ce86da 🔖 dashboard v0.14.2 2022-09-21 10:02:49 +08:00
naiba 06d1707b2f 🐛 修复更新 tag 绑定关系导致的问题 2022-09-21 10:01:41 +08:00
naiba ac328205ca Merge pull request #248 from naiba/l10n_master [no ci]
New Crowdin updates
2022-09-21 10:00:48 +08:00
naiba 4e60648240 New translations zh-CN.toml (English) 2022-09-21 09:58:52 +08:00
naiba 04582f4775 Merge pull request #247 from naiba/l10n_master
New Crowdin updates
2022-09-18 00:11:59 +08:00
naiba b6d3e0e2fb New translations zh-CN.toml (English) 2022-09-17 23:49:32 +08:00
naiba 2321309eb9 New translations zh-CN.toml (Spanish) 2022-09-17 23:49:32 +08:00
naiba a02e23237c New translations zh-CN.toml (Spanish) 2022-09-17 20:59:22 +08:00
naiba 8ac928d5ab 🚀 dashboard v0.14.2 服务延迟报警 2022-09-17 10:39:22 +08:00
naiba b115edd5b9 Merge pull request #246 from naiba/l10n_master [no ci]
New Crowdin updates
2022-09-17 10:38:26 +08:00
naiba c3a63ad66d New translations zh-CN.toml (English) 2022-09-17 10:37:16 +08:00
naiba d1a2274f88 Update source file zh-CN.toml 2022-09-17 10:36:26 +08:00
naiba c707774512 New translations zh-CN.toml (English) 2022-09-17 10:31:09 +08:00
naiba c509be0c3a New translations zh-CN.toml (Spanish) 2022-09-17 10:31:08 +08:00
naiba 53c4130292 Update source file zh-CN.toml 2022-09-17 10:31:03 +08:00
naiba 0bcbf34403 服务延迟报警 [no ci] 2022-09-17 10:30:32 +08:00
naiba 574963b232 🚀 dashboard v0.14.1 批量修改分组 2022-09-17 10:00:53 +08:00
naiba 65429b19db Merge pull request #245 from naiba/l10n_master [no ci]
New Crowdin updates
2022-09-17 09:59:27 +08:00
naiba 6a0e59e7fc New translations zh-CN.toml (English) 2022-09-17 09:57:19 +08:00
naiba 8bb1964e1b New translations zh-CN.toml (Spanish) 2022-09-17 09:57:18 +08:00
naiba 82afd5e6b0 Update source file zh-CN.toml 2022-09-17 01:30:01 +08:00
naiba 4e75ffaf6d New translations zh-CN.toml (English) 2022-09-17 00:10:43 +08:00
naiba 521d84166c New translations zh-CN.toml (Spanish) 2022-09-17 00:10:42 +08:00
naiba 9a3c31154f ✨ batch update server tag 2022-09-17 00:08:27 +08:00
naiba d92fecf621 Merge pull request #244 from naiba/l10n_master
New Crowdin updates
2022-09-15 10:42:20 +08:00
naiba 5d3feb0004 New translations zh-CN.toml (English) 2022-09-15 10:00:55 +08:00
naiba 739153d53d New translations zh-CN.toml (Spanish) 2022-09-15 10:00:54 +08:00
naiba 22991b65c2 🚀 dashboard & agent v0.14.0 2022-09-15 09:17:10 +08:00
naiba e4b9acf398 Merge pull request #243 from naiba/l10n_master [no ci]
New Crowdin updates
2022-09-15 09:09:51 +08:00
naiba 6026b83254 New translations zh-CN.toml (English) 2022-09-15 08:32:48 +08:00
naiba 265bc8b401 New translations zh-CN.toml (Spanish) 2022-09-15 08:32:47 +08:00
naiba c90df10e5c New translations zh-CN.toml (English) 2022-09-15 01:17:32 +08:00
naiba 735d500b0a New translations zh-CN.toml (Spanish) 2022-09-15 01:17:31 +08:00
naiba 188ae1a756 New translations zh-CN.toml (English) 2022-09-15 00:11:23 +08:00
naiba a68458d6b1 New translations zh-CN.toml (Spanish) 2022-09-15 00:11:20 +08:00
naiba 1a8ac3758c Merge pull request #242 from naiba/l10n_master
New Crowdin updates
2022-09-14 22:36:17 +08:00
naiba 9c26b5730b New translations zh-CN.toml (English) 2022-09-14 22:34:11 +08:00
naiba 3e3d6d80a2 New translations zh-CN.toml (Spanish) 2022-09-14 22:34:10 +08:00
naibaandAkkiaS7 f4dc6c2cef Merge pull request #241 from AkkiaS7/trigger-task
feat: 报警规则触发任务执行

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-09-14 22:31:08 +08:00
naiba c8f6c5172e ⬆️ upgrade dependencies 2022-09-14 22:26:14 +08:00
naiba 75494fa380 fix typo 2022-09-14 22:14:53 +08:00
naiba df2e8aac06 rename CronCoverSelf to CronCoverAlertTrigger 2022-09-14 22:14:47 +08:00
Akkia c114083a44 为新增字段提供默认值 2022-09-14 12:16:24 +08:00
Akkia 201214fd54 update for CodeQL 2022-09-14 11:37:00 +08:00
Akkia 1066915e5c 前端更新: 支持报警规则绑定触发任务 2022-09-14 11:32:15 +08:00
Akkia c900941f3b feat: 报警规则支持绑定触发任务 2022-09-14 11:14:23 +08:00
Akkia 8aa2e83dca fix: 移除服务监控设置页面重复生成的id 2022-09-14 11:06:59 +08:00
Akkia 048dd35129 添加触发任务类型 2022-09-13 06:14:47 +08:00
Akkia eefa7b8473 前端更新: 匹配新的报警规则设置页面 2022-09-13 04:02:34 +08:00
Akkia 8ae2de3882 feat: 触发报警后的通知支持仅发送一次 2022-09-13 04:01:08 +08:00
naibaandKorenKrita 710ad13f15 Merge pull request #240 from KorenKrita/bugfix/fixArchLinuxIcon
fixArchLinuxIcon

Co-authored-by: KorenKrita <22239339+KorenKrita@users.noreply.github.com>
2022-08-26 23:56:36 +08:00
KorenKrita 2fb4797709 add arch type 2022-08-26 23:25:05 +08:00
naiba 4c3b080487 🔥 Q群解散倒计时 [no ci] 2022-08-23 22:48:26 +08:00
naiba d75d5f82fd ⬆️ Upgrade dependencies 2022-08-13 11:40:10 +08:00
naiba 78e5ffc0ec improve install_en.sh [no ci] 2022-08-03 10:39:28 +08:00
naiba dd99594df7 fix: zh install script [no ci] 2022-07-27 09:55:11 +08:00
naiba 30958d1a12 🎉 reduce docker image build time
Merge pull request #238 from naiba/dev
2022-07-07 15:13:39 +08:00
naiba 799ea43868 💚 add arg 2022-07-07 14:45:03 +08:00
naiba 2a37edf850 💚 remove i386 2022-07-07 14:09:01 +08:00
naiba 11a57d5a54 test 2022-07-07 13:54:19 +08:00
naiba 6f4f380ff4 💚 fix dockerfile 2022-07-07 13:38:44 +08:00
naiba 8b6745ebf1 💚 arm-7 2022-07-07 13:14:07 +08:00
naiba ff93d9516f 💚 fix arm/v7 2022-07-07 12:13:21 +08:00
naiba 7bc0ab63f2 👷 update 2022-07-07 11:50:25 +08:00
naiba 63e5876a67 💚 use xgo 2022-07-07 11:21:54 +08:00
naiba 8968517656 💚 override entrypoint 2022-07-07 10:52:21 +08:00
naiba 4405d8a406 💚 fix DOCKER_USERNAME: unbound variable 2022-07-07 10:35:59 +08:00
naiba 6af93f1a62 👷 building docker iamges with new methods 2022-07-07 10:27:14 +08:00
naiba 2b44a7cd00 🔖 dashboard v0.13.32 2022-07-05 23:48:58 +08:00
naiba e458c09c6f Merge pull request #236 from AkkiaS7/master [skip ci]
update: 未获取到country code时继续查询下一个API
2022-07-05 23:44:53 +08:00
Akkia 602b511dcd update: 未获取到country code时继续查询下一个API 2022-07-05 20:08:57 +08:00
naibaandErope eda10bda24 Merge pull request #235 from Erope/master
Add FreeBSD Agent For Goreleaser

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-06-28 21:49:14 +08:00
王佳辉 a6e91402aa Add FreeBSD Agent For Goreleaser 2022-06-28 20:34:59 +08:00
naiba dc19008265 🚀 dashboard v0.13.31 bytecdn -> staticfile 2022-06-24 20:43:38 +08:00
naibaandErope 452e4cb068 Merge pull request #234 from Erope/master [no ci]
Change bytecdntp to staticfile

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-06-24 20:30:10 +08:00
王佳辉 0c67c95ade Change bytecdntp to staticfile 2022-06-24 20:25:20 +08:00
naiba 9417367ee7 🚨 fix warnings [no ci] 2022-06-23 22:56:34 +08:00
naibaanddysf888 ee2f580f0c Merge pull request #233 from dysf888/patch-1 [no ci]
limit ps version

Co-authored-by: dysf888 <47450409+dysf888@users.noreply.github.com>
2022-06-23 14:11:59 +08:00
dysf888 172fe86bc1 limit ps version
replace ghproxy to dn-dao-github-mirror.daocloud.io
revised some tips
2022-06-23 14:03:03 +08:00
naiba be78e8a92f 🚀 dashboard v0.13.30 2022-06-22 12:53:21 +08:00
naiba 7262e01086 Merge pull request #232 from dysf888/patch-1 [no ci]
fix 未能创建 SSL/TLS 安全通道
2022-06-22 12:43:54 +08:00
dysf888 4ef8cd70b7 fix 未能创建 SSL/TLS 安全通道
fix 未能创建 SSL/TLS 安全通道
2022-06-22 12:16:04 +08:00
naiba 1b0265f2d0 🚀 dashboard v0.13.29 前端断开后自动重连 2022-06-08 18:51:47 +08:00
naiba 13e29700f8 [skip ci] 2022-06-07 21:50:18 +08:00
naiba 412d664e56 Update dashboard.yml 2022-06-07 21:42:53 +08:00
naibaandhhhkkk520 25eb42ff4d 🌐 dashboard v0.13.28
Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-06-07 18:56:57 +08:00
naiba 96dd107d4d Merge pull request #231 from naiba/l10n_master [no ci]
New Crowdin updates
2022-06-07 18:54:42 +08:00
naiba 76d6c8b415 New translations zh-CN.toml (Spanish) 2022-06-07 18:45:55 +08:00
naibaandErope d6a93a3f01 Merge pull request #230 from Erope/master
一键脚本语言跟随面板语言

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-06-03 20:16:49 +08:00
Erope c5e3821fc3 一键脚本语言跟随面板语言 2022-06-03 19:11:24 +08:00
naiba 0a1b67d3c0 🚨 fix: gosec lint 2022-06-03 10:16:33 +08:00
naiba aa7d4292f1 ✏️ typo [no ci] 2022-06-03 10:09:39 +08:00
naiba 42e8974736 🚀 dsahboard v0.13.26 custom dashboard theme 2022-06-03 10:02:11 +08:00
naiba a21d515813 Merge pull request #229 from naiba/l10n_master [no ci]
New Crowdin updates
2022-06-03 10:00:19 +08:00
naiba db1fdc0d4d New translations zh-CN.toml (English) 2022-06-03 09:57:43 +08:00
naiba 11c78d99b1 New translations zh-CN.toml (Spanish) 2022-06-03 09:57:42 +08:00
naiba 58f9e6c006 Update source file zh-CN.toml 2022-06-03 09:57:38 +08:00
naiba d109d056af New translations zh-CN.toml (English) 2022-06-03 09:50:33 +08:00
naiba dd179559d1 New translations zh-CN.toml (Spanish) 2022-06-03 09:50:32 +08:00
naiba 0f99a30c4c ✨ custon dashboard template [no ci] 2022-06-03 09:45:11 +08:00
naibaandliuyanxi975 9ea6ab80ca Merge pull request #227 from liuyanxi975/master
流量剩余显示增加详细信息

Co-authored-by: liuyanxi975 <24417037+liuyanxi975@users.noreply.github.com>
2022-05-31 18:49:47 +08:00
liuyanxi975 cbcee8cfd1 修正细节 2022-05-31 15:12:15 +08:00
liuyanxi975 728e31f460 流量剩余显示增加详细信息
增加流量剩余显示的详细剩余流量
改进CSS,优化显示效果
2022-05-31 13:18:20 +08:00
0559320741 🔖 dsahboard v0.13.25
Co-authored-by: liuyanxi975 <24417037+liuyanxi975@users.noreply.github.com>
Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-30 19:18:00 +08:00
naiba f02408a4d7 Merge pull request #224 from naiba/l10n_master [no ci]
New Crowdin updates
2022-05-30 19:16:19 +08:00
naiba af7a832bae New translations zh-CN.toml (English) 2022-05-30 19:15:52 +08:00
naiba bb120bd950 New translations zh-CN.toml (Spanish) 2022-05-30 19:15:51 +08:00
naiba 9599e83c9e Update source file zh-CN.toml 2022-05-30 19:01:40 +08:00
naibaandErope 78b9c8f552 Merge pull request #225 from Erope/master [no ci]
Add timeout limit for wget

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-30 19:01:15 +08:00
Erope a0d32b9c57 Add timeout limit for wget 2022-05-30 18:49:10 +08:00
naiba 7a79c26833 New translations zh-CN.toml (English) 2022-05-30 18:33:21 +08:00
naiba 7afbfbb97f New translations zh-CN.toml (Spanish) 2022-05-30 18:33:20 +08:00
naibaandErope 2361f20bd8 Merge pull request #222 from Erope/master [no ci]
Add retry times limit for wget

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-30 18:05:08 +08:00
naibaandliuyanxi975 4987c5eff7 Merge pull request #223 from liuyanxi975/master [no ci]
改进剩余流量显示

Co-authored-by: liuyanxi975 <24417037+liuyanxi975@users.noreply.github.com>
2022-05-30 18:04:41 +08:00
liuyanxi975 0e81e6571f 修正
修复负值显示错误
改进页面显示
2022-05-30 17:51:11 +08:00
liuyanxi975 b4c295c531 改进剩余流量显示
改为后端运行。
2022-05-30 17:21:21 +08:00
Erope 0ecc825b5d Add retry times limit for wget 2022-05-30 15:34:30 +08:00
naiba d09c62fcb0 💄 dashboard v0.13.24 remaining cycle transfer 2022-05-30 09:39:19 +08:00
naiba 5c71cc9d42 Merge pull request #219 from liuyanxi975/master [no ci]
默认主题增加流量剩余显示
2022-05-30 09:34:42 +08:00
naibaandErope 53a8de8022 Merge pull request #221 from Erope/master [no ci]
修改面板配置时不直接修改

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-30 00:08:45 +08:00
Erope ae3872c060 修改面板配置时不直接修改 2022-05-29 23:05:12 +08:00
naibaandErope e4ab5af94a Merge pull request #220 from Erope/master
Fix 修改main.js的版本参数避免缓存问题

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-29 22:45:10 +08:00
naiba 59216f1e65 bump version 2022-05-29 22:44:37 +08:00
Erope 90ed02c5ce Fix 修改main.js的版本参数避免缓存问题 2022-05-29 22:37:36 +08:00
liuyanxi975 af2b05e819 默认主题增加流量剩余显示 2022-05-29 21:35:27 +08:00
naiba c25adc28ff 🌐 update 2022-05-28 17:26:36 +08:00
naiba ec38468cb2 Merge pull request #218 from naiba/l10n_master [no ci]
New Crowdin updates
2022-05-28 17:25:28 +08:00
naiba c14609d0bc New translations zh-CN.toml (English) 2022-05-28 17:20:14 +08:00
naiba 9f06297cba installer v0.10.3 [no ci] 2022-05-27 09:51:03 +08:00
naiba d58eb460b2 ✨ dashboard v0.13.20 Login with Gitlab、Jihulab 2022-05-26 20:00:20 +08:00
naiba 375dc7958e Merge pull request #217 from Erope/master [no ci]
JSdelivr -> Jihulab
2022-05-26 11:06:36 +08:00
Erope a9a09b5424 JSdelivr -> Jihulab 2022-05-26 10:55:14 +08:00
naibaandErope bdfa0274d2 Merge pull request #216 from Erope/master
Add CGO for Mac

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-23 21:21:32 +08:00
Erope 3eebb87166 Add CGO for Mac 2022-05-23 20:45:46 +08:00
naibaanddysf888 c8f657fb2c Merge pull request #215 from dysf888/master
删除多余}  水pr=,=

Co-authored-by: dysf888 <47450409+dysf888@users.noreply.github.com>
2022-05-23 17:12:38 +08:00
dysf888 7e5207ae74 删除多余} 2022-05-23 11:59:20 +08:00
naiba f91ae087b6 Update README.md [no ci] 2022-05-21 15:42:42 +08:00
naibaandhhhkkk520 b014252bb6 Merge pull request #214 from naiba/l10n_master
New Crowdin updates

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-21 10:37:21 +08:00
naiba f83542831f New translations zh-CN.toml (English) 2022-05-21 10:20:01 +08:00
naibaandhhhkkk520 ea64ab9ab8 Merge pull request #213 from hhhkkk520/master [no ci]
更新README、删除旧文档、更新英文文档链接

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-21 08:51:16 +08:00
hhhkkk520 6e65e6abcc 更新README、删除旧文档、更新英文文档链接 2022-05-20 21:18:19 +02:00
naiba b57335e3e4 ⚡️ dashboard v0.13.19 全面替换为字节前端 CDN 2022-05-20 21:18:47 +08:00
naibaandAkkiaS7 e7807ab298 Merge pull request #211 from AkkiaS7/feat-api
update: 服务器状态API增加上次汇报时间的unix时间戳

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-05-20 11:00:58 +08:00
naiba dcd9ef00f6 🎉 dashboard v0.13.18 2022-05-20 11:00:30 +08:00
Akkia f6c49a20c5 update: 服务器状态API增加上次汇报时间的unix时间戳 2022-05-20 10:46:44 +08:00
naiba 2dcbd1d0e0 ✏️ 2022-05-20 09:05:18 +08:00
naiba 5a60a2323b 🐛 dashboard v0.13.17 fix indexOf element in slice 2022-05-20 09:03:58 +08:00
naiba 9633662708 🚀 dashboard v0.13.16 自定义外部主题 2022-05-19 09:46:11 +08:00
naibaandhhhkkk520 f749f6bd01 Merge pull request #210 from naiba/l10n_master [no ci]
New Crowdin updates

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-19 09:08:46 +08:00
naiba d7b9b14874 New translations zh-CN.toml (Spanish) 2022-05-19 08:56:15 +08:00
naiba e7f9e3cdf2 New translations zh-CN.toml (English) 2022-05-19 01:03:58 +08:00
988cbc5d0e 🚀 dashboard v0.13.15 API 支持
Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-19 00:07:56 +08:00
naibaandhhhkkk520 9d600f9cd5 Merge pull request #209 from naiba/l10n_master [no ci]
New Crowdin updates

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-19 00:05:42 +08:00
naiba 8706e3ea52 New translations zh-CN.toml (English) 2022-05-19 00:04:45 +08:00
naiba 4a20084c2d New translations zh-CN.toml (Spanish) 2022-05-19 00:04:44 +08:00
naiba df730db4aa Update source file zh-CN.toml 2022-05-19 00:04:03 +08:00
naiba 255b1bc699 New translations zh-CN.toml (Spanish) 2022-05-18 23:57:15 +08:00
naiba 97e54e6832 New translations zh-CN.toml (English) 2022-05-18 23:57:15 +08:00
naibaandAkkiaS7 4fe26fd965 Merge pull request #206 from AkkiaS7/feat-api [no ci]
feat: API支持

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-05-18 23:56:07 +08:00
Akkia 5d15dd0112 update: 单例模式 2022-05-18 23:54:22 +08:00
Akkia 9a658a5694 update: 代码格式|前端入口移动至用户名下拉菜单 2022-05-18 23:37:39 +08:00
Akkia a44b02487d Merge branch 'master' into feat-api 2022-05-18 20:53:13 +08:00
Akkia ae7b56eaf4 add: API Token相关的前端页面 2022-05-18 20:52:18 +08:00
naibaandhhhkkk520 bfa4045c28 Merge pull request #208 from hhhkkk520/master [no ci]
Update README.md

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-18 11:33:37 +08:00
hhhkkk520 4deb7c9f59 Create README.md 2022-05-18 05:26:18 +02:00
hhhkkk520 d1809ef89e Update README.md 2022-05-18 05:25:17 +02:00
Akkia 30f891b7eb typo 2022-05-18 11:16:14 +08:00
Akkia b61eb3d1e2 update: 线程安全 2022-05-18 10:28:24 +08:00
Akkia fb527d2202 feat: Token生成|存储|验证 2022-05-18 10:10:35 +08:00
Akkia 670c786a8f Merge upstream commit 2022-05-17 22:15:27 +08:00
naibaandErope d57171ef15 Merge pull request #207 from Erope/master
Custom DNS

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-17 20:54:36 +08:00
naiba 82bdb76d66 🎉 dashboard v0.13.14 2022-05-17 20:54:01 +08:00
Akkia 3984c466a9 feat: 服务器信息API 2022-05-17 20:17:02 +08:00
Erope 8ecbe3718d Custom DNS 2022-05-17 20:09:49 +08:00
Akkia d70ebf4d20 feat: 服务器状态API 2022-05-17 11:22:56 +08:00
naibaandErope 053f260e5c Merge pull request #205 from Erope/master [no ci]
Add update before fetch

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-17 00:27:43 +08:00
Erope 671917633e Merge branch 'master' of github.com:Erope/nezha 2022-05-16 23:31:15 +08:00
Erope c7398a7480 Add update before fetch 2022-05-16 23:30:45 +08:00
naibaandGreenTeodoro839 d12684ff07 Merge pull request #204 from GreenTeodoro839/master [no ci]
Add tls support

Co-authored-by: GreenTeodoro839 <77104800+GreenTeodoro839@users.noreply.github.com>
2022-05-15 00:02:46 +08:00
GreenTeodoro839 b9a2812b5c Add tls support
tls spport for Windows installer
2022-05-14 22:02:08 +08:00
naibaandErope 6882a14166 Merge pull request #203 from Erope/master [no ci]
Fix SELinux for more system

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-14 14:54:14 +08:00
Erope 1fb88c980a Change version 2022-05-14 13:22:11 +08:00
Erope 0b9d3cf397 Fix SELinux for more system 2022-05-14 13:21:42 +08:00
naibaandErope cf169a7806 Merge pull request #202 from Erope/master [no ci]
Add SELinux Check

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-13 20:07:32 +08:00
Erope fe04a9c539 Add SELinux Check 2022-05-13 19:29:03 +08:00
naibaanddysf888 16e88940b0 Merge pull request #201 from dysf888/master [no ci]
Co-authored-by: dysf888 <47450409+dysf888@users.noreply.github.com>
2022-05-10 13:38:26 +08:00
dysf888 53d3c56678 😥fix改名引起的自更新失效
fix改名引起的自更新失效
2022-05-10 12:56:37 +08:00
dysf888 c74bc09d09 Win重复运行自更新
判断C:/nezha文件夹是否存在
2022-05-10 12:37:30 +08:00
naibaandErope a3bd54ca84 🎉 installer v0.10.0 [no ci]
Fix README && Update Script

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-07 23:09:20 +08:00
naiba 56bcd9cdee 🎉 installer v0.10.0 2022-05-07 23:08:30 +08:00
Erope 1842ac8c9e Update En Script 2022-05-07 22:58:47 +08:00
Erope a6fd2017bc Fix Special Thanks 2022-05-07 22:52:28 +08:00
Erope b55e2cae2f Merge branch 'master' of github.com:Erope/nezha 2022-05-07 22:49:46 +08:00
Erope 44fca2c3ca Add reinstall dashboard warning && Update README 2022-05-07 22:49:25 +08:00
naibaandMikoyChinese bf09805f7d Merge pull request #196 from MikoyChinese/theme-mdui
Update theme-mdui homepage and some url.

Co-authored-by: MikoyChinese <22676744+MikoyChinese@users.noreply.github.com>
2022-05-06 18:47:50 +08:00
Mikoy Chinese a9d83bd6df Update theme-mdui homepage and some url. 2022-05-06 18:20:15 +08:00
naiba 8a7939a03b 💄 dashboard v0.13.13 优化国旗和操作系统图标间隔 2022-05-06 09:46:25 +08:00
naiba 9715ba2b1c 💄 dashboard v0.13.12 优化 amazon linux、pve 系统图标 2022-05-05 22:39:52 +08:00
naiba 1c23f0a4d0 🐛 fix vue :class 2022-05-05 21:55:27 +08:00
naiba 172973d244 🍱 revert brand.svg [skip ci] 2022-05-05 21:01:28 +08:00
naiba 8a312e6a29 💄 dashboard v0.13.10 优化操作系统图标 2022-05-05 20:55:23 +08:00
naibaandxh33006 c4ab181bbf Merge pull request #195 from xh33006/xh33006-patch-1 [skip ci]
国旗颜色不够鲜艳

Co-authored-by: xh33006 <33201711+xh33006@users.noreply.github.com>
2022-05-05 20:27:10 +08:00
xh33006 d63b237985 国旗颜色不够鲜艳
修正国旗星星朝向
2022-05-05 19:55:34 +08:00
xh33006 0ed460a0eb Add files via upload
火火火火火柴 考研结束 寄了, [2022/5/5 18:47]
[回复 Sean]
改完了PR来
2022-05-05 19:05:22 +08:00
naiba c8db609165 Merge branch 'master' of github.com:naiba/nezha 2022-05-05 10:57:11 +08:00
naiba d5a9b98393 🍱 update brand.svg [skip ci] 2022-05-05 10:57:05 +08:00
naibaandErope 57bba996d1 Merge pull request #193 from Erope/master [skip ci]
fix typo

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-05-02 22:29:38 +08:00
Erope 68dc3e487d fix typo 2022-05-02 22:16:05 +08:00
naiba 4224ee99b2 🔖 dashboard v0.13.9 ignore monitor unmarshal error 2022-05-02 13:36:49 +08:00
naiba 2b4b60dbf2 ♻️ refactor lock of service data 2022-05-02 10:33:21 +08:00
naiba 3d0b304af1 fix: gosec check 2022-05-02 00:53:39 +08:00
naiba 2dd93cfce6 fix: service total uptime rate close #191 2022-05-02 00:45:03 +08:00
naibaandhhhkkk520 4270a421ca Merge pull request #190 from naiba/l10n_master
New Crowdin updates

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-02 00:39:48 +08:00
naiba 3305383726 New translations zh-CN.toml (English) 2022-05-02 00:39:23 +08:00
naiba 42ecb218b5 New translations zh-CN.toml (Spanish) 2022-05-02 00:39:22 +08:00
naiba d1a239b4f0 Update source file zh-CN.toml 2022-05-02 00:36:37 +08:00
naiba b19379ab07 New translations zh-CN.toml (Spanish) 2022-05-01 23:58:49 +08:00
naiba 0d32619b1a New translations zh-CN.toml (English) 2022-05-01 23:58:48 +08:00
naiba a4d3b5fa55 fix: missed l10n [skip ci] 2022-05-01 23:57:49 +08:00
naiba ebe668fffb 🔖 dashboard v0.13.6 2022-05-01 22:54:08 +08:00
naibaandhhhkkk520 dd4dfd9b44 Merge pull request #189 from hhhkkk520/master [skip ci]
重写英文文档,优化中文文档,修复了一些文本显示错误

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-01 22:50:55 +08:00
naiba 44b465362d 🔖 dashboard v0.13.5 update Español 2022-05-01 22:45:40 +08:00
Kris 9fa4bdd8e9 Rewrite the English guide and fix some text errors 2022-05-01 16:41:03 +02:00
naiba 40eebfc8b4 🍱 skip ci 2022-05-01 22:34:40 +08:00
naiba 1e9f2d8840 🍱 logo update [skip ci] 2022-05-01 22:31:22 +08:00
naibaandhhhkkk520 de37fc1bcc Merge pull request #188 from naiba/l10n_master
New Crowdin updates

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-01 22:03:17 +08:00
naiba ea70db6526 New translations zh-CN.toml (Spanish) 2022-05-01 11:47:19 +08:00
naiba 69e0061a74 New translations zh-CN.toml (Spanish) 2022-05-01 10:49:05 +08:00
naiba 25066d7f6d 🔖 dashboard v0.13.4 Español 2022-05-01 09:32:17 +08:00
naibaandAkkiaS7 14c3a77e98 Merge pull request #187 from AkkiaS7/master [skip ci]
fix: 恢复错误修改的Tag

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-05-01 09:27:01 +08:00
naibaandhhhkkk520 52b45d6d6a Merge pull request #185 from naiba/l10n_master [skip ci]
New Crowdin updates

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-05-01 09:25:38 +08:00
naiba bed9463d5a New translations zh-CN.toml (Spanish) 2022-05-01 04:12:45 +08:00
naiba e2b1f81dee New translations zh-CN.toml (Spanish) 2022-05-01 03:00:43 +08:00
naiba 5b118d0d03 New translations zh-CN.toml (Spanish) 2022-05-01 02:04:13 +08:00
Akkia 718ad798e5 fix: 恢复错误修改的Tag 2022-05-01 01:56:03 +08:00
naiba 3e41b15f88 New translations zh-CN.toml (Spanish) 2022-05-01 01:02:08 +08:00
naiba e9bf14ace1 New translations zh-CN.toml (Spanish) 2022-04-30 23:48:21 +08:00
naiba 429ad932a8 New translations zh-CN.toml (English) 2022-04-30 23:48:20 +08:00
naiba f5444616a2 Update source file zh-CN.toml 2022-04-30 23:48:19 +08:00
naibaandAkkiaS7 bcdd7a5360 Merge pull request #186 from AkkiaS7/master [skip ci]
update: 使用 "ServerGroup" 标签在服务器相关页面

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-30 23:10:04 +08:00
naiba 8aedc2b6bc ♻️ refactor settings page [skip ci] 2022-04-30 23:02:40 +08:00
Akkia c6fcd9a2a1 update: 使用 "ServerGroup" 标签在服务器相关页面 2022-04-30 23:01:57 +08:00
naiba 2e08d22275 New translations zh-CN.toml (Spanish) 2022-04-30 22:49:27 +08:00
naiba 43d6a01a39 🚸 imporve redirect page 2022-04-30 21:39:25 +08:00
naiba e26fc7f650 Merge branch 'master' of github.com:naiba/nezha 2022-04-30 21:34:05 +08:00
naiba 1512193e22 🔖 dashboard v0.13.3 improve l10n 2022-04-30 21:30:58 +08:00
naiba e622c7e2d6 Merge pull request #184 from naiba/l10n_master [skip ci]
New Crowdin updates
2022-04-30 21:28:40 +08:00
naiba a2bbdda24b New translations zh-CN.toml (English) 2022-04-30 21:27:44 +08:00
naiba 5b087bbb71 🔖 dashboard v0.13.2 improve l10n 2022-04-30 19:31:14 +08:00
naiba 56e82b3267 Merge pull request #183 from naiba/l10n_master [skip ci]
New Crowdin updates
2022-04-30 19:30:09 +08:00
naiba 11abfc4fad New translations zh-CN.toml (English) 2022-04-30 19:28:54 +08:00
naiba 54ed5b66dd Update source file zh-CN.toml 2022-04-30 19:27:35 +08:00
naiba 4ef1de977c New translations zh-CN.toml (English) 2022-04-30 19:24:01 +08:00
naiba 43d99981af 💬 update notification text [skip ci] 2022-04-30 19:23:19 +08:00
naiba bf2be25723 Merge pull request #182 from naiba/l10n_master [skip ci]
New Crowdin updates
2022-04-30 16:59:02 +08:00
naiba fe95aec0ef New translations zh-CN.toml (English) 2022-04-30 16:57:22 +08:00
naiba b074a5717c New translations zh-CN.toml (English) 2022-04-30 16:37:41 +08:00
naiba 10bd0d91ed Update source file zh-CN.toml 2022-04-30 16:37:40 +08:00
naiba f56ab30c39 📝 update README.md [skip ci] 2022-04-30 15:53:21 +08:00
naibaandMartijnLindeman 4f1edb0980 Merge pull request #181 from MartijnLindeman/patch-2 [skip ci]
Update UserGuide_en.md

Co-authored-by: MartijnLindeman <78365708+MartijnLindeman@users.noreply.github.com>
2022-04-30 15:41:48 +08:00
naiba 1e0c308fa0 improve 2022-04-30 15:41:06 +08:00
naiba f060d84741 Merge pull request #180 from naiba/l10n_master [skip ci]
New Crowdin updates
2022-04-30 10:25:44 +08:00
naiba e2f234c973 Merge branch 'master' into l10n_master 2022-04-30 10:25:29 +08:00
naiba 128a930cdc New translations zh-CN.toml (English) 2022-04-30 10:20:39 +08:00
naiba 079562251e Update source file zh-CN.toml 2022-04-30 10:20:34 +08:00
naiba b2a8083865 New translations zh-CN.toml (English) 2022-04-30 09:33:49 +08:00
naiba 5d0808cf07 🌐 dashboard v0.13.1 improve localization 2022-04-30 09:32:57 +08:00
naibaandhhhkkk520 877c321fa6 Merge pull request #179 from hhhkkk520/master
修改了一些文本和格式错误

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-04-30 08:13:19 +08:00
Martijn Lindeman cc5667fd1e Update UserGuide_en.md 2022-04-29 19:10:49 +01:00
Kris 53ceab8982 Fix some text errors 2022-04-29 20:06:25 +02:00
naiba a9277b156e 🔖 dashboard v0.13.0 Globalization 2022-04-30 00:48:39 +08:00
naibaandErope e2762fb38e Merge pull request #178 from Erope/master
Translate the installation script

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2022-04-30 00:35:44 +08:00
卖女孩的小火柴 b0b58652bc Merge branch 'naiba:master' into master 2022-04-30 00:29:55 +08:00
Erope 42bd911070 Translate the installation script 2022-04-30 00:29:17 +08:00
naibaandAkkiaS7 81706a3a80 Merge pull request #176 from naiba/l10n_master [skip ci]
New Crowdin updates

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-30 00:06:16 +08:00
naiba 14299e82e2 📝 [WIP] English documents [skip ci] 2022-04-29 23:53:02 +08:00
naiba 4dc45e6177 New translations zh-CN.toml (English) 2022-04-29 23:38:15 +08:00
naiba 05bbc4686a Update source file zh-CN.toml 2022-04-29 23:38:14 +08:00
naiba 89ea809a5c New translations zh-CN.toml (English) 2022-04-29 22:36:52 +08:00
naibaandhhhkkk520 e483601c54 Merge pull request #175 from hhhkkk520/master [skip ci]
提取全部主题文本到 zh-CN.toml

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-04-29 22:35:49 +08:00
naiba 07250b6e17 improve 2022-04-29 22:35:17 +08:00
hhhkkk520 f646e56816 Merge branch 'naiba:master' into master 2022-04-29 16:25:01 +02:00
Kris 5858bbc698 Update all theme texts 2022-04-29 15:59:39 +02:00
Kris 01561b1cdc Update theme-default and zh-CN.toml 2022-04-29 14:49:24 +02:00
naibaanddysf888 023b50074f Merge pull request #173 from dysf888/master [skip ci]
😉新增region判断

Co-authored-by: dysf888 <47450409+dysf888@users.noreply.github.com>
2022-04-29 20:33:29 +08:00
dysf888 757ba0e93d 😉新增region判断
使用https://api.myip.com/ 无CF检查
$region -eq $null 暂未遇见,暂留
2022-04-29 19:00:45 +08:00
naibaandAkkiaS7 e4b0fcc4ca New translations [skip ci]
New Crowdin updates

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-29 18:25:06 +08:00
naiba 272e8ced42 New translations zh-CN.toml (English) 2022-04-29 16:38:26 +08:00
naiba a577dc84a6 New translations zh-CN.toml (English) 2022-04-29 15:15:04 +08:00
naiba 545410b61e New translations zh-CN.toml (English) 2022-04-29 13:56:31 +08:00
naiba 3f6e7eaab0 New translations zh-CN.toml (English) 2022-04-29 12:40:08 +08:00
naiba b29b9538ef Update source file zh-CN.toml 2022-04-29 12:40:07 +08:00
naibaandhhhkkk520 137a678e10 Merge pull request #171 from hhhkkk520/master [skip ci]
提取文本到zh-CN.toml

Co-authored-by: hhhkkk520 <52115472+hhhkkk520@users.noreply.github.com>
2022-04-29 11:47:52 +08:00
naiba fe1e92c1be fix typo 2022-04-29 11:45:41 +08:00
naiba cf6d445039 New translations zh-CN.toml (English) 2022-04-29 11:23:11 +08:00
hhhkkk520 4074b866ef Merge branch 'master' into master 2022-04-29 05:21:47 +02:00
Kris 664c4fd2a8 modified: resource/l10n/zh-CN.toml
modified:   resource/template/common/menu.html
	modified:   resource/template/component/confirm.html
	modified:   resource/template/component/cron.html
	modified:   resource/template/component/monitor.html
	modified:   resource/template/component/notification.html
	modified:   resource/template/component/rule.html
	modified:   resource/template/component/server.html
	modified:   resource/template/dashboard/cron.html
	modified:   resource/template/dashboard/error.html
	modified:   resource/template/dashboard/login.html
	modified:   resource/template/dashboard/monitor.html
	modified:   resource/template/dashboard/notification.html
	modified:   resource/template/dashboard/server.html
	modified:   resource/template/dashboard/setting.html
	modified:   resource/template/dashboard/terminal.html
2022-04-29 04:52:26 +02:00
naibaandhmsjy2017 a01a99fef5 dashboard v0.12.30 替换被污染的 CDN
fix `tar: This does not look like a tar archive`

Co-authored-by: hmsjy2017 <42692274+hmsjy2017@users.noreply.github.com>
2022-04-29 09:30:49 +08:00
naiba 36afc9ceea dashboard v0.12.30 替换被污染的 CDN 2022-04-29 09:29:20 +08:00
naiba 2301315a46 替换 jsdelivr 污染域名 2022-04-29 09:27:11 +08:00
naiba 87a64e3936 Merge branch 'master' into master 2022-04-29 09:12:25 +08:00
naibaanddysf888 11d82840ee dashboard v0.12.29 Windows Agent 一键安装
新增win一键 (need PS 管理员权限)

Co-authored-by: dysf888 <47450409+dysf888@users.noreply.github.com>
2022-04-29 09:09:11 +08:00
naiba 1671bc9f74 dashboard v0.12.29 Windows Agent 一键安装 2022-04-29 09:07:57 +08:00
dysf888 47be5599c5 🙈修改忘改的tar to unzip
🙈修改忘改的tar to unzip
2022-04-29 03:26:07 +08:00
dysf888 2b81c1d10c 🔖支持PowerShell WIN一键
PowerShell(管理员)
2022-04-29 03:24:52 +08:00
dysf888 528fcd7cff 🤪增加win installer
🤪增加win installer
2022-04-29 03:23:33 +08:00
dysf888 c30b487f15 Update README.md 2022-04-29 03:22:37 +08:00
Tony c3d689a5c8 fix tar: This does not look like a tar archive 2022-04-29 00:47:16 +08:00
naiba a006302ce4 🔖 installer v0.8.3 [skip ci] 2022-04-29 00:20:18 +08:00
Kris 86ea38b875 modified: resource/l10n/zh-CN.toml
modified:   resource/template/common/menu.html
2022-04-28 18:13:32 +02:00
naiba 796947bd55 🔖 release 改为 .zip 2022-04-28 23:39:44 +08:00
“kris” afad62370b First Commit 2022-04-28 17:35:11 +02:00
naiba b5f4abf369 🌐 i18n config file [skip ci] 2022-04-28 10:17:38 +08:00
naiba 32d5805415 Merge pull request #167 from naiba/l10n_master [skip ci]
New Crowdin updates
2022-04-28 09:33:11 +08:00
naiba 7addb0c576 New translations zh-CN.toml (English) 2022-04-28 03:51:47 +08:00
naiba e47aed8c30 Merge pull request #166 from naiba/l10n_master [skip ci]
New Crowdin updates
2022-04-28 00:02:34 +08:00
naiba a21457cbb8 New translations zh-CN.toml (English) 2022-04-28 00:00:38 +08:00
naiba 25ed355682 Update Crowdin configuration file 2022-04-28 00:00:12 +08:00
naiba 53b6b9fcea 🌐 localization [skip ci] 2022-04-27 23:51:45 +08:00
naiba 397817d960 🔖 dashboard v0.12.27 取消用户名unique,有些用户 gitee/github 用户名相同导致无法启动 2022-04-26 22:54:40 +08:00
naiba 892eba3619 ⬆️ dashboard v0.12.26 update gorm fields tags 2022-04-26 12:46:44 +08:00
naiba 6d8db0c45b 🗑️ 移除失效 GeoIP API 2022-04-26 09:26:52 +08:00
naiba 5e97dbff22 🔖 dashboard v0.12.25 refactor oauth2 login 2022-04-26 09:20:32 +08:00
naibaandAkkiaS7 bf1a2e9817 Merge pull request #165 from AkkiaS7/enhance-notification
update: #SERVER.IP#仅返回一个IP(优先返回ipv4地址)| 新增获取ipv4与ipv6地址的占位符

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-24 21:30:13 +08:00
naiba a928c5323b dashboard v0.12.24 2022-04-24 21:29:45 +08:00
Akkia 9563d28df2 Merge branch 'nezha-master' into enhance-notification 2022-04-24 21:21:43 +08:00
Akkia 9d2a0b9f7e update: #SERVER.IP#仅返回一个IP(优先返回ipv4地址)| 新增获取ipv4与ipv6地址的占位符 2022-04-24 21:21:03 +08:00
naibaandAkkiaS7 20ed9c3c8a Merge pull request #164 from AkkiaS7/enhance-notification
feat: 添加/修改通知方式时可选择不发送测试信息

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-23 16:21:50 +08:00
Akkia f99729e8ea Merge branch 'enhance-notification' of https://github.com/AkkiaS7/nezha into enhance-notification 2022-04-23 14:51:26 +08:00
Akkia 76f247eb86 update: 格式修改 2022-04-23 14:51:10 +08:00
Akkiaandnaiba 4d4f13282e Update cmd/dashboard/controller/member_api.go
Co-authored-by: naiba <hi@nai.ba>
2022-04-23 14:46:33 +08:00
naiba c7165df2e7 dashboard v0.12.23 2022-04-23 09:51:24 +08:00
Akkia 2d952cb17e update: 表格内容自动换行 2022-04-23 02:49:45 +08:00
Akkia f7f2440049 feat: 添加/修改通知方式时可选择不发送测试信息 2022-04-23 02:07:46 +08:00
naibaandAkkiaS7 fcb094ef7c Merge pull request #163 from AkkiaS7/enhance-notification
update: 增加TCP连接数与UDP连接数的占位符

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-22 17:03:06 +08:00
naiba 4c80b7c9aa dashboard v0.12.22 2022-04-22 17:02:25 +08:00
Akkia 8644853ab3 update: 增加TCP连接数与UDP连接数的占位符 2022-04-22 16:30:05 +08:00
naibaandAkkiaS7 b29081bd5a Merge pull request #162 from AkkiaS7/enhance-notification
feat: 添加更多的占位符以支持基于服务器状态指标构造自定义的HTTP请求

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-18 21:15:06 +08:00
naiba 60d0c852e4 dashboard v0.12.21 2022-04-18 21:10:07 +08:00
Akkia 70ba6eb3f9 update: 在 添加/编辑通知方式页面 增加可用的占位符标识提示 2022-04-18 20:55:36 +08:00
naiba f82d63fe8e fix code scan alerts 2022-04-18 20:45:07 +08:00
Akkia 56e9f5a637 update: 发送消息前对服务器状态进行拷贝 保证消息发送的状态指标与触发该请求的状态指标相同 2022-04-18 19:59:42 +08:00
Akkia 035aaf30ad Merge branch 'nezha-master' into enhance-notification 2022-04-18 15:22:48 +08:00
naiba 921b92f1cc Create SECURITY.md [skip ci] 2022-04-18 10:47:29 +08:00
naiba 2d6066c81e Create codeql-analysis.yml 2022-04-18 10:43:42 +08:00
Akkia 0d3d1e2e91 Merge branch 'nezha-master' into enhance-notification 2022-04-17 21:07:19 +08:00
Akkia e29161782c update: 增加更多占位符与单元测试 2022-04-17 21:06:30 +08:00
Akkia 6e14bdb4eb optimize: 在查找到Server对象后移除ServerID标识符 2022-04-17 18:08:30 +08:00
Akkia 0f02d8c0a0 WIP: 添加更多的占位符以支持基于服务器状态指标构造自定义的HTTP请求 2022-04-17 17:38:45 +08:00
naibaandAkkiaS7 1bd90922a2 Merge pull request #161 from AkkiaS7/master
optimise: 优化websocket数据包发送逻辑 加快首次访问时监控信息载入速度

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-16 19:54:54 +08:00
Akkia 982658ac10 optimise: 优化websocket数据包发送逻辑 加快首次访问时监控信息载入速度 2022-04-16 19:21:20 +08:00
naibaandAkkiaS7 6aad7e2e64 Merge pull request #160 from AkkiaS7/enhance-notification
feat: 通知方式分组 支持将不同的报警|监控|计划任务的通知 发送到指定的通知分组

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-16 09:35:59 +08:00
naibaandAkkiaS7 ebe663c245 dashboard v0.12.20
Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-16 09:32:34 +08:00
Akkia f64c7b6301 fix: 修复修改通知方式所在组后 不会移除其原有map映射关系的问题 2022-04-15 23:23:51 +08:00
Akkia a048663c67 fix: 前向兼容性 | for range pointer issue 2022-04-15 20:34:25 +08:00
Akkia 5e5048fa2a update: 前端增加通知方式组相关选项 2022-04-15 10:56:04 +08:00
Akkia 050c613ec3 update: 调度失败的计划任务仅通知其所属的通知组 2022-04-15 10:55:21 +08:00
Akkia 9ac2052fbf fix "for range" pointer issue 2022-04-15 03:20:42 +08:00
Akkia a7261b1510 WIP: 补全各模块的通知分组设置 2022-04-15 03:13:53 +08:00
Akkia 4d2ee1fc14 Merge branch 'master' into enhance-notification 2022-04-14 21:22:37 +08:00
Akkia 2ebcc13ab8 WIP: 通知方式分组 支持将不同的报警|监控|计划任务的通知 发送到指定的通知分组 2022-04-14 21:06:42 +08:00
naiba ff94e8809a ⬆️ Upgrade dependencies 2022-04-14 16:41:34 +08:00
naibaandAkkiaS7 b8ab9d0812 Merge pull request #159 from AkkiaS7/master
feat: 通过传递客户端Cookie的方式使web终端功能兼容被Cloudflare Access保护的面板

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-13 16:52:15 +08:00
naiba 00d7dbc34b bump dashboard version 2022-04-13 16:51:57 +08:00
Akkia 733c041949 为cloudflareCookies增加基本的合法性验证 2022-04-13 16:45:39 +08:00
Akkiaandnaiba e8e4127d9e Update cmd/dashboard/controller/common_page.go
Co-authored-by: naiba <hi@nai.ba>
2022-04-13 15:18:10 +08:00
Akkiaandnaiba 51dfbaa915 Update cmd/dashboard/controller/common_page.go
Co-authored-by: naiba <hi@nai.ba>
2022-04-13 15:18:05 +08:00
Akkia 8ea8755d81 Merge branch 'master' of https://github.com/AkkiaS7/nezha 2022-04-13 12:28:45 +08:00
Akkia 494f8db7e4 feat: 通过传递客户端Cookie的方式使web终端兼容被Cloudflare Access保护的面板 2022-04-13 12:27:40 +08:00
naibaandAkkiaS7 0d71d0c961 Merge pull request #158 from AkkiaS7/master
optimize:移除两处冗余的代码 + refactor:优化代码组织结构

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-12 20:15:22 +08:00
naiba fd8755b2c6 typo 2022-04-12 20:11:19 +08:00
Akkia b6f5088beb 更新 UpdateIP 注释 2022-04-12 19:02:22 +08:00
Akkia 4bee826252 optimize: 优化自动更新相关代码 2022-04-12 18:05:26 +08:00
Akkia 09d3607cee refactor: 优化代码组织结构 2022-04-12 13:16:33 +08:00
Akkia b648550595 optimize: 移除两处冗余的代码 (初始化后未使用|重复初始化) 2022-04-12 00:01:38 +08:00
naibaandAkkiaS7 5d6ff61c77 Merge pull request #157 from AkkiaS7/master [skip ci]
增加代码注释

Co-authored-by: AkkiaS7 <68485070+AkkiaS7@users.noreply.github.com>
2022-04-11 23:07:28 +08:00
Akkia 9776cf6182 添加代码注释+修正一个typo 2022-04-11 22:51:02 +08:00
naiba 07f5f081f7 Merge pull request #156 from AkkiaS7/master [skip ci]
fix some typo
2022-04-10 20:37:59 +08:00
Akkia a282383b0d fix some typo 2022-04-10 20:28:22 +08:00
naiba 7e8aa65879 ✨ Agent 获取 IP 增加 api.myip.la 2022-04-05 11:54:38 +08:00
naiba c27f191949 ✨ Dashboard s390x 2022-04-03 11:23:37 +08:00
naiba 2fdd153124 revert: 随机API获取IP(双栈支持问题) 2022-04-01 08:30:44 +08:00
naiba 9b1e39e1c1 installer: 替换 ip.sb 到 ipapi 2022-03-31 23:02:41 +08:00
naiba e1e1687f00 💩 优化 CountryCode 获取 2022-03-31 22:59:07 +08:00
naiba ed7ed6dbb7 💩 优化获取 GeoIP 2022-03-31 22:45:26 +08:00
naiba a7907750a2 ⚡️ 提升「服务」页面抗击打能力 2022-03-24 20:22:05 +08:00
naiba 0c8a80f8f2 🐛 修复默认主题模板 2022-03-23 22:04:33 +08:00
naiba 5f9929bdf5 ⚡️ 增强抗击打能力 2022-03-23 21:07:38 +08:00
naiba 7aa29b2469 ⬆️ Upgrade to Go v1.18.0 2022-03-22 18:46:18 +08:00
naiba 81f3d5c4ef ✨ 可选监控哪些分区及网卡 2022-03-19 17:22:15 +08:00
naiba 83e4e2a195 🐛 修复 Windows time.LoadLocation 2022-03-18 23:57:30 +08:00
naiba 2c404b76a1 🐛 修复 Windows time.LoadLocation 2022-03-18 23:45:03 +08:00
naiba a90c0400d9 ⚡️ 使用 json-iterator 替换 encoding/json 进行一些序列化/反序列化操作 2022-03-18 23:13:22 +08:00
naiba a368d07da7 ⚡️ WS 通信阻塞读写锁 2022-03-13 10:44:30 +08:00
naiba 4dc51c403e 🚸 调整展示时间 timezone 2022-03-01 10:19:23 +08:00
naiba 41ba57637f ✨ 可选的通知信息 IP 不打码 2022-02-19 14:29:06 +08:00
naiba f158cb0372 ⬆️ Upgrade dependencies 2022-02-05 21:30:01 +08:00
naiba 9ee235d0f2 💩 强制 windows 运行对应正确二进制 2022-01-18 21:07:16 +08:00
naiba b76e52b076 💩 2022-01-18 20:41:51 +08:00
naiba 06835ffd0f 🐛 服务监控 bug 2022-01-14 20:16:00 +08:00
naibaandMikoyChinese be35410164 Merge pull request #155 from MikoyChinese/theme-mdui [skip ci]
修复管理后台页面新添加CoverIgnoreAll类型服务需要重启docker镜像的问题.

Co-authored-by: MikoyChinese <22676744+MikoyChinese@users.noreply.github.com>
2022-01-14 21:13:50 +09:00
MikoyChinese cc589cc134 修复管理后台页面新添加CoverIgnoreAll类型服务需要重启docker镜像的问题. 2022-01-14 17:34:49 +08:00
naibaandMikoyChinese 4b23a4fc17 Merge pull request #154 from MikoyChinese/theme-mdui
Round check next time format to second

Co-authored-by: MikoyChinese <22676744+MikoyChinese@users.noreply.github.com>
2022-01-14 14:18:57 +09:00
MikoyChinese b4a832f7d0 Round check next time format to second. 2022-01-14 12:01:55 +08:00
MikoyChinese 0cc409a788 Fix theme-mdui homepage MEM progress offline issue. 2022-01-14 12:01:08 +08:00
naiba f0d8d13988 🎨 增强月流量统计 @MikoyChinese 2022-01-13 15:14:40 +08:00
naibaandMikoyChinese 6ef18f0a80 Merge pull request #153 from MikoyChinese/theme-mdui [skip ci]
添加统计周期单位cycle_unit

Co-authored-by: MikoyChinese <22676744+MikoyChinese@users.noreply.github.com>
2022-01-13 12:57:11 +05:45
MikoyChinese 7d64255ef9 Merge branch 'naiba-master' into theme-mdui 2022-01-13 14:41:01 +08:00
MikoyChinese e1f786af05 Merge branch 'master' of https://github.com/naiba/nezha into naiba-master 2022-01-13 14:40:34 +08:00
MikoyChinese 6d94dd9294 Update README.md for cycle_unit usage. 2022-01-13 14:03:38 +08:00
MikoyChinese be81c12bd3 Add cycle_unit to Network Traffic Statistics. 2022-01-13 13:46:41 +08:00
MikoyChinese 8f8612b52b Change circle interval to crontab expression. 2022-01-11 18:15:43 +08:00
MikoyChinese e38ed001d3 Update README.md for 'cycle_start'. 2022-01-11 18:14:31 +08:00
naibaandMikoyChinese de434b9a20 💄 mdui update
Co-authored-by: MikoyChinese <22676744+MikoyChinese@users.noreply.github.com>
2022-01-10 19:27:03 +08:00
naibaandMikoyChinese 3a1b50db35 Merge pull request #151 from MikoyChinese/theme-mdui [skip ci]
Fix viewpassword and Add Network Traffic Statistics in service page.

Co-authored-by: MikoyChinese <22676744+MikoyChinese@users.noreply.github.com>
2022-01-10 13:25:45 +02:00
MikoyChinese 2c972a1f41 Fix viewpassword bug. 2022-01-10 18:36:38 +08:00
MikoyChinese cda6fa1ca9 Add Network Traffic Statistics in service page. 2022-01-10 18:36:21 +08:00
MikoyChinese 956d6f3c44 Improve init Home page. 2022-01-10 18:35:47 +08:00
naiba 7b26191f66 💄 mdui update 2022-01-10 09:38:55 +08:00
naibaandMikoyChinese 8f6d6e5e1e Merge pull request #150 from MikoyChinese/theme-mdui
Update realtime tooltip and fix some bugs.

Co-authored-by: MikoyChinese <22676744+MikoyChinese@users.noreply.github.com>
2022-01-10 03:36:04 +02:00
Mikoy From ThinkPad e353986312 Update realtime tooltip and fix some bugs. 2022-01-10 00:28:58 +08:00
naiba f123936b06 Update README.md 2022-01-09 01:55:46 -03:00
naiba 5bdfc0e052 theme-mdui by @MikoyChinese 2022-01-09 11:54:14 +08:00
naibaandMikoyChinese 16cfce325d Merge pull request #149 from MikoyChinese/theme-mdui [skip ci]
Add theme-mdui.

Co-authored-by: MikoyChinese <22676744+MikoyChinese@users.noreply.github.com>
2022-01-09 09:39:24 +06:00
Mikoy From ThinkPad 396e8608ab Add theme-mdui. 2022-01-08 23:54:24 +08:00
naiba 687e8cb1de 🚚 更换到字节跳动前端 CDN 2021-12-30 20:36:32 +08:00
naibaandCosmosZ-code aa70113aa0 Merge pull request #147 from CosmosZ-code/patch-3 [skip ci]
Update header.html

Co-authored-by: CosmosZ-code <81398224+CosmosZ-code@users.noreply.github.com>
2021-12-30 14:28:17 +02:00
naibaandCosmosZ-code b605d4399d Merge pull request #146 from CosmosZ-code/patch-2 [skip ci]
Update footer.html

Co-authored-by: CosmosZ-code <81398224+CosmosZ-code@users.noreply.github.com>
2021-12-30 14:27:55 +02:00
CosmosZ-code fc2c801a09 Update header.html
改为字节前端,同时保证国内国际速度
2021-12-30 19:09:33 +08:00
CosmosZ-code 47b643feef Update footer.html
对比了一下,字节的速度和bootcdn国内差别不大,好在字节有国际节点,loli.net使用了日本节点,部分地区无法访问,我把把这些改成了字节。
2021-12-30 19:06:57 +08:00
naibaandErope 2c86a7550a Merge pull request #148 from Erope/master
Add SS Func to accelerate connections count in some Linux

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2021-12-30 12:56:36 +02:00
CosmosZ-code 4464b235ad Update footer.html 2021-12-30 18:51:05 +08:00
Erope ac783d13b0 Add SS Func to accelerate connections count in some Linux 2021-12-30 18:01:32 +08:00
CosmosZ-code 279a2aeed5 Update header.html
将jsdelivrCDN替换bootcdn,提升国内访问速度
2021-12-30 17:48:32 +08:00
CosmosZ-code 610962d660 Update footer.html
将jsdelivrCDN改为bootcdn,提升国内访问速度
2021-12-30 17:47:08 +08:00
naibaandErope eb8df1ccd1 Merge pull request #145 from Erope/master [skip ci]
Add OpenWRT_Nezha Project

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2021-12-29 14:28:22 +00:00
Erope e01a736e5b Add OpenWRT_Nezha Project 2021-12-29 21:34:10 +08:00
naiba 9747540ada 🚸 自动更新月流量统计起止时间 2021-12-28 23:47:19 +08:00
naibaandErope 47c63f0d63 Merge pull request #144 from Erope/master
Agent支持IBM-S390X和RISCV64架构

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2021-12-02 16:18:57 +01:00
Erope e88e9b8d5c Agent支持IBM-S390X和RISCV64架构 2021-12-02 22:55:21 +08:00
naiba 6c2b8d7205 🚸 消除手动触发与自动触发的差异 2021-11-28 10:05:07 +08:00
naibaandnickfox-taterli a445268c78 🔖 dashboard v0.12.0 agent v0.11.7
Co-authored-by: nickfox-taterli <19658596+nickfox-taterli@users.noreply.github.com>
2021-11-25 20:20:36 +08:00
naibaandnickfox-taterli 5b151d68eb Merge pull request #143 from nickfox-taterli/master
添加磁盘空间获取的Fallback方法,应对OVZ无法统计问题!

Co-authored-by: nickfox-taterli <19658596+nickfox-taterli@users.noreply.github.com>
2021-11-25 13:08:30 +01:00
TaterLi 011f7898c8 添加磁盘空间获取的Fallback方法,应对OVZ无法统计问题! 2021-11-25 19:22:52 +08:00
naiba 0cafb040ce ✨ installer 0.8.0 2021-11-13 15:35:37 +08:00
naiba be356fe2b2 ⬆️ 升级依赖、修复一个内存泄露问题 2021-11-11 21:40:13 +08:00
naiba 669e54432f 🔖 dashboard v0.11.8 / agent v0.11.5 2021-11-11 14:14:49 +08:00
naibaandlemoeo cd69dd6f06 Merge pull request #142 from lemoeo/main
✨ 反向代理 gRPC 端口(支持 Cloudflare CDN)

Co-authored-by: lemoeo <18618627+lemoeo@users.noreply.github.com>
2021-11-11 13:13:20 +07:00
Lemoe 2874157c2e ✨ 反向代理 gRPC 端口(支持 Cloudflare CDN) 2021-11-11 14:09:42 +08:00
Lemoe b982d9291a ✨ 反向代理 gRPC 端口(支持 Cloudflare CDN) 2021-11-11 13:46:32 +08:00
Lemoe a8cb14b270 ✨ 反向代理 gRPC 端口(支持 Cloudflare CDN) 2021-11-11 12:52:37 +08:00
naiba 62e1e00189 💄 优化循环流量展示 2021-11-11 09:40:10 +08:00
naiba 937d074cea 🐛 goreleaser GITHUB_TOKEN 2021-11-09 10:16:23 +08:00
naiba ff68969057 🔊 修复 release log 每次只生成一条的问题 2021-11-09 10:12:47 +08:00
naiba e080d90aba 📝 更新月流量统计说明 2021-11-08 09:59:52 +08:00
naiba 42c81f6119 🔖 dashboard v0.11.6 2021-11-06 18:53:19 +08:00
naiba b31b77cdcd 🐛 int64 导致 seconds 永远为 0 2021-11-06 18:52:10 +08:00
naiba 6b5f3d9e06 🐛 流量记录删除条件 typo 2021-11-06 18:40:29 +08:00
naiba 24bfeb94ac ✨ 展示月流量使用情况 2021-11-06 16:00:08 +08:00
naibaandlemoeo 4843da234c Merge pull request #141 from lemoeo/main
agent 增加 SSL/TLS 选项

Co-authored-by: lemoeo <18618627+lemoeo@users.noreply.github.com>
2021-11-06 18:05:58 +13:00
Lemoe c5cdf5bbe7 Update README.md 2021-11-06 13:03:56 +08:00
Lemoe 0af550ba9f 增加 nginx 反代 grpc 开启 Cloudflare CDN 的说明 2021-11-06 12:57:26 +08:00
Lemoe df7d1cd6c4 设置 TLS MinVersion 2021-11-06 11:42:23 +08:00
Lemoe d594548505 agent 增加 SSL/TLS 选项 2021-11-06 09:46:44 +08:00
naiba d5e59984b7 ✅ add tests 2021-11-05 15:49:01 +08:00
naiba 2700d08079 🐛 默认请求方式 2021-11-05 14:53:27 +08:00
naiba ca5b31c406 🔖 dashboard v0.11.1 2021-11-05 12:17:08 +08:00
naiba 9a056d7a4c ♻️ set content type 2021-11-05 12:16:41 +08:00
naiba 6b47af1d5e ♻️ 通知支持自定义 request header 2021-11-05 12:04:39 +08:00
naiba 26f64d39ad 🐛 修复 arm/mips 的自动更新问题 2021-11-04 19:52:15 +08:00
naiba a7ceb35404 🐛 2021-11-04 13:52:45 +08:00
naiba 7407529ba2 💚 fix gosec 2021-11-04 13:02:07 +08:00
naiba 62c19cfac2 💚 更新 Go 版本 2021-11-04 12:55:12 +08:00
naiba 452ed4df2f ✨ 批量更新 Agent 2021-11-04 12:06:20 +08:00
naiba 3b9921d597 🔖 archive file name 2021-11-03 21:48:24 +08:00
naiba 410c40f53d 👷 goreleaser config update 2021-11-03 21:40:22 +08:00
naiba b3db0b5882 👷 macOS universal binaries support 2021-11-03 21:05:18 +08:00
naiba 5cbb4bd870 👷 macOS universal binaries support 2021-11-03 09:36:18 +08:00
naiba e089aa236d 🚸 更新迁移说明和卸载命令 2021-10-31 16:14:23 +08:00
naiba 02a3ca8e2a ⚡️ 优化历史服务监控数据写入 2021-10-12 22:46:55 +08:00
naiba 0121352b7b 🔒️ 允许禁止命令执行 2021-10-11 19:44:23 +08:00
naiba 34c302b3d1 🔒️ 允许禁止命令执行 [skip ci] 2021-10-11 19:41:19 +08:00
naiba 1e0ad3f23f 🐛 任务调度中,服务器长度缩短导致的问题 2021-10-10 23:52:25 +08:00
naiba 59081e3b55 ✏️ typo [skip ci] 2021-10-10 00:12:38 +08:00
naiba a2872b91bd 📝 Update README.md [skip ci] 2021-10-09 18:09:32 +02:00
naiba 233035ff56 🔖 v0.10.5 2021-10-10 00:04:52 +08:00
naiba 8e8f5d8d8a ⚡️ 进一步提供选项降低 agent 占用 2021-10-10 00:03:02 +08:00
naiba 5d5ffe66b9 🐛 修复 WebSSH 连接断开后遗留僵尸进程 2021-09-30 12:01:01 +08:00
naiba 78793cba3f 🐛 修改计划任务并未取消旧任务 2021-09-30 11:59:57 +08:00
naiba 56f5b15163 🔥 移除后台登录类型设置,增加调度失败计划任务提醒 2021-09-30 00:01:04 +08:00
naiba 82d9ef431e 🐛 修复启动时间错误 2021-09-29 21:17:53 +08:00
naiba 3a4061fc8a Revert "🐛 修复启动时间错误"
This reverts commit 513dc04c5d.
2021-09-29 21:17:15 +08:00
naiba 46fa47d521 🐛 修复启动时间错误 2021-09-29 21:13:16 +08:00
naiba 75612e934f ✅ 更新IP脱敏测试用例 [skip ci] 2021-09-29 20:14:25 +08:00
naiba 7e5a91160a ⚡️ pprof 性能调优 / 计划任务支持秒级 2021-09-29 19:58:02 +08:00
naiba 3cfbcaa22c 🐛 服务请求结果接收 2021-09-27 23:07:05 +08:00
naiba 2d14a24f63 🏷️ 忽略升级任务上报 [skip ci] 2021-09-27 21:30:43 +08:00
naiba 079e14c2b1 ✏️ [skip ci] typo 2021-09-27 21:26:56 +08:00
naiba 9987ec988f 🧐 预置升级任务,Agent 可禁止自动升级 2021-09-27 21:24:04 +08:00
naiba a6c2a6fdfe 🔖 dashboard v0.10.0 2021-09-27 21:18:09 +08:00
naiba 6db297ba91 💚 #nosec 2021-09-04 12:54:05 +08:00
naiba 849aa335af ✅ update ci 2021-09-04 12:51:02 +08:00
naiba b6dde7d324 ✅ gosec 2021-09-04 12:42:51 +08:00
naiba 16b0bab2ab ✏️ [skip ci] 2021-09-04 12:21:11 +08:00
naiba 5adef0c6a4 👷 2021-09-04 12:19:59 +08:00
naiba 2f3a6d2f0e 👷 [skip ci] gosec code scanner 2021-09-04 12:18:12 +08:00
naiba c6cb6b8a93 🐛 dashboard: 修复服务监控任务调度 2021-09-03 08:52:45 +08:00
naiba e288e4581a 📝 2021-09-02 23:46:10 +08:00
naiba 3e8bb5bbfd ✨ dashboard: 服务监控请求时间间隔 2021-09-02 23:45:21 +08:00
naiba 2e876411d6 Update README.md 2021-09-02 07:39:30 +02:00
naiba 5ee137035f 🐛 dashboard: 出站月流量统计typo 2021-08-29 14:41:22 +08:00
naiba 7d7859d06a ⚡️ agent: SSL证书校验优化 2021-08-29 14:41:00 +08:00
naiba de6c868222 🔊 dashboard: 默认输出服务故障上报日志 2021-08-27 21:04:37 +08:00
naiba 42d248f760 ✨ agent: 统计rclone挂载 2021-08-27 20:30:36 +08:00
naiba fbd43b8afb Update README.md 2021-08-23 10:18:43 +08:00
naiba e583841026 🛂 查看密码导致的 WebSSH 不可用 2021-08-20 13:50:25 +08:00
naiba 4f588c49ae 🚸 v0.9.29 WebSSH ping-pong 保活 2021-08-20 10:45:10 +08:00
naiba bab2fb5a41 🗑 2021-08-20 00:02:53 +08:00
naiba cbffc328bd ⚡️ fix Agent 重复取进程数 2021-08-19 23:57:45 +08:00
naiba 6bd51cb561 fix 2021-08-19 19:49:04 +08:00
naiba fded49cd98 🚸 放宽 Agent 权限使 WebSSH 更灵活 2021-08-19 14:22:21 +08:00
naiba 0983edddfa 🔖 installer 0.7.0 2021-08-19 12:32:36 +08:00
naiba ca7f1058af 🐛 range 了一个 closed channel :LOL: 2021-08-19 11:43:30 +08:00
naiba f428712d7d 🐛 前端小错误 2021-08-19 10:42:47 +08:00
naiba 7b80d50bb7 🔖 v0.9.26 Terminal 优化 推荐升级 2021-08-19 10:23:06 +08:00
naiba 05c652331a 🚸 优化 Terminal 连接健壮性、输入缓存 2021-08-19 10:21:54 +08:00
naiba 3c648936bf 📝 Caddy v1 Terminal 路径反代 2021-08-19 09:26:57 +08:00
naiba a0f23673b2 📝 添加 Terminal 一直黑屏解决方案 2021-08-19 09:22:48 +08:00
naiba 4bff0695c1 🐛 修复 Windows WebTerminal 2021-08-19 00:04:09 +08:00
naiba 033ba293d8 💩 ghcr 在搞什么鬼? 2021-08-18 18:53:35 +08:00
naiba c0ebfeef3d 👷 使用 GITHUB_TOKEN 给 ghcr 鉴权 2021-08-18 18:05:23 +08:00
naiba 9e2ac9b8ea ✨ v0.9.22 WebSSH 2021-08-18 17:42:26 +08:00
naiba 2dcdbea874 ✨ v0.9.21 WebSSH 2021-08-18 11:56:54 +08:00
naiba 48e7c64452 ✨ [agent] 可关闭的进程数连接数监控 2021-08-16 23:26:38 +08:00
naiba 1a14fee958 🔖 v0.9.20 2021-08-16 10:27:59 +08:00
naiba 54a303ad70 🐛 修复30天服务状态的更新 2021-08-16 10:27:21 +08:00
naiba 3f6576933f 📝 update 2021-08-15 16:40:52 +08:00
naiba 489152aaec ✨ [0.9.19] 进程数/连接数/负载 监控报警 2021-08-15 16:38:05 +08:00
naiba 8fd0dec164 📝 增加 Agent 问题自检说明 2021-08-10 23:48:26 +08:00
naiba cbaaddc891 🐛 修复一键复制问题 2021-08-10 23:21:39 +08:00
naiba 7862a12eff Merge pull request #139 from cloverzrg/hotfix210810
修复脚本无法修改agent配置的问题
2021-08-11 02:02:08 +11:00
Zhu Rengen 53e24f089d Update README.md 2021-08-10 22:52:34 +08:00
Zhu Rengen 9af11df826 Update install.sh 2021-08-10 22:50:33 +08:00
Zhu Rengen e7adadd28f Update install.sh 2021-08-10 22:48:30 +08:00
naibaandcloverzrg 0f0625ed51 ⬆️ bump installer version from 0.6.5 to 0.6.6
Co-authored-by: cloverzrg <13957666+cloverzrg@users.noreply.github.com>
2021-08-10 20:16:42 +08:00
naiba 971dbc5c2a 🔖 [dashboard 0.9.17] 404 页面 2021-08-10 20:13:17 +08:00
naiba 84b3f789ad ✨ Linux 一键安装命令 2021-08-10 20:04:58 +08:00
Zhu Rengen cf74970316 update 2021-08-10 15:46:19 +08:00
Zhu Rengen 7946e168c0 update 2021-08-10 15:45:19 +08:00
Zhu Rengen 81049b6477 update 2021-08-10 15:45:09 +08:00
Zhu Rengen 1685673376 add GRPCHost config 2021-08-10 15:44:59 +08:00
Zhu Rengen 00bb9a51e1 update 2021-08-10 15:42:43 +08:00
Zhu Rengen 679a5b219f Update install.sh 2021-08-10 15:41:15 +08:00
Zhu Rengen d65db0687e Update home.html 2021-08-10 15:37:52 +08:00
naiba 7f45150a1c 📝 Agent 无法启动一般解决办法 2021-08-09 14:55:08 +08:00
naiba 05d6b69fea 🐛 修复 Windows SwapMem 统计 2021-08-08 10:06:44 +08:00
naibaandxiongbao 65e03090f9 ✨ [0.9.15] 整体配色升级
Co-authored-by: xiongbao <4247191+xiongbao@users.noreply.github.com>
2021-08-06 11:50:00 +08:00
naiba b7901bd776 其他按钮、文字等配色细节 2021-08-06 11:48:34 +08:00
naibaandxiongbao 12040ee47b 熊大给出的配色建议
Co-authored-by: xiongbao <4247191+xiongbao@users.noreply.github.com>
2021-08-06 10:43:21 +08:00
naiba bff426c0b1 📝 Update README.md 2021-08-04 13:52:47 +08:00
naiba d288d9da49 💄 更新 LOGO 2021-08-04 13:35:41 +08:00
naiba ab74f31ad6 📝 agent 更新 2021-08-03 22:10:55 +08:00
naibaandCoiaPrant 6c5a7426a2 Merge pull request #137 from CoiaPrant/master
修复LXC容器的读取内存和Swap错误

Co-authored-by: CoiaPrant <42506847+CoiaPrant@users.noreply.github.com>
2021-08-03 17:09:32 +03:00
CoiaPrant e0a3002823 修复LXC容器的读取内存和Swap错误 2021-08-03 22:01:33 +08:00
naibaandCoiaPrant dd5932bda7 Merge pull request #136 from CoiaPrant/patch-1
支持输入错误时删除

Co-authored-by: CoiaPrant <42506847+CoiaPrant@users.noreply.github.com>
2021-08-03 16:29:50 +03:00
CoiaPrant 59179aaa83 Update README.md 2021-08-03 19:54:53 +08:00
CoiaPrant 83e5964330 Update install.sh 2021-08-03 19:51:15 +08:00
CoiaPrant b9e5443a96 支持输入错误时删除 2021-08-02 13:28:56 +08:00
naiba 61ef3243fe Merge pull request #134 from naiba/dependabot/go_modules/github.com/gin-gonic/gin-1.7.0
Bump github.com/gin-gonic/gin from 1.6.3 to 1.7.0
2021-07-26 20:05:13 -06:00
dependabot[bot] e10763f3b0 Bump github.com/gin-gonic/gin from 1.6.3 to 1.7.0
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.6.3 to 1.7.0.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.6.3...v1.7.0)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-07-27 01:59:42 +00:00
naiba c4257ad105 🐛 修复修改服务器导致 Agent 离线 2021-07-25 23:50:08 +08:00
naibaandch8o 4ad4bd9d5a Merge pull request #133 from ch8o/ch8o
修复修改密钥后,agent连不上bug

Co-authored-by: ch8o <9103372+ch8o@users.noreply.github.com>
2021-07-22 02:44:05 -06:00
ch8o 014a151cd4 修复修改密钥后,agent连不上bug 2021-07-22 15:27:20 +08:00
ch8o 2b3c3ae7df 修复修改密钥后,agent连不上bug 2021-07-22 14:30:16 +08:00
naiba 8aa9e5e7b5 ✏️ typo 2021-07-19 20:32:07 +08:00
naiba e40c1d1a6e 🗑 优化无效数据清理 2021-07-19 20:27:21 +08:00
naiba db74f11395 👷 提升健壮性 2021-07-19 17:38:18 +08:00
naiba 3a464f9bd9 🐛 移除调试使用的参数 2021-07-19 11:17:42 +08:00
naiba ea10e297b4 ♻️ 错误处理优化 2021-07-19 10:44:44 +08:00
naiba 911c964152 🐛 修复 cron/v3 表达式 2021-07-19 10:37:12 +08:00
naiba 8ecddbdb26 ⚡️ 服务监控数据加载优化 2021-07-17 13:53:13 +08:00
naiba 0e05d0a021 🐛 修复区间流量统计 2021-07-16 18:09:50 +08:00
naibaandacgpiano d90255fdf5 Merge pull request #132 from acgpiano/master
解决排除网卡不生效的bug

Co-authored-by: acgpiano <15900800+acgpiano@users.noreply.github.com>
2021-07-16 09:56:16 +03:00
atm b3142ae26b 解决排除网卡不生效的bug 2021-07-16 14:49:15 +08:00
naiba 4ce9ef67e0 Merge branch 'master' of github.com:naiba/nezha 2021-07-16 13:59:42 +08:00
naiba 52a5509cb0 💄 修复LXC百分比溢出 2021-07-16 13:59:27 +08:00
naiba 9c12e8e408 Update README.md 2021-07-16 08:19:51 +05:00
naiba 3701f0c166 🚸 使用服务器端时间判断机器离线 2021-07-16 11:14:07 +08:00
naiba 21d3dc95d4 ⚡️ 流量记录清理 2021-07-15 00:07:02 +08:00
naiba f7e9ea72dd ✨ 任意时间区间(月流量)统计,无视重启~ 2021-07-14 23:53:37 +08:00
naiba b293242088 💩 update 2021-07-13 11:09:58 +08:00
naiba 0b84216cfb 🚸 优化NAT64机器取IP场景 2021-07-13 10:59:13 +08:00
naiba 0ae031047d Merge branch 'master' of github.com:naiba/nezha 2021-07-12 14:32:47 +08:00
naiba 90f6522223 ⚡️ 优化Agent体积和逻辑 2021-07-09 00:01:58 +08:00
naibaandErope <44471469+Erope@users.noreply.github.com 481f7a094f Merge pull request #131 from Erope/master
面板安装支持ARM和x86

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com
2021-07-08 00:04:29 +00:00
Erope c303ca9c56 面板安装支持ARM和x86 2021-07-08 03:04:46 +08:00
卖女孩的小火柴 82002ea326 Update dashboard.yml 2021-07-08 02:38:35 +08:00
卖女孩的小火柴 67c42c0467 增加ARM的dashboard支持 2021-07-08 02:35:23 +08:00
卖女孩的小火柴 49fd867d45 Update dashboard.yml 2021-07-08 01:54:44 +08:00
naiba e19691838e 🚸 AWS 系统镜像适配 2021-07-07 13:27:07 +08:00
naibaandErope 25904c1668 Update README.md
Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2021-07-06 21:52:34 -05:00
naibaandErope 63fd568a8c Merge pull request #130 from Erope/master
借用jsdelivr获取版本号

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2021-07-07 00:50:05 -02:00
Erope 8a9f3eaf33 修改install版本号 2021-07-06 22:51:46 +08:00
Erope 13bbfafd45 借用jsdelivr获取版本号 2021-07-06 21:50:49 +08:00
naiba 63ebd18a27 🔖 dashboard v0.8.6 2021-07-06 11:58:48 +08:00
naiba bf4869316e 💄 修复主题错误 2021-07-06 11:57:53 +08:00
naiba 37f890491f 紧急处理 GeoIP API 问题 2021-07-05 13:44:21 +08:00
naiba 5f46036f0e 优化 agent 信息上报与 ARM 安装面板的错误提示 2021-06-30 18:15:53 +08:00
naiba fc5b51d0e5 📝 update README.md 2021-06-24 12:22:53 +08:00
naiba 1986297f99 ♻️ 优化 IPv4 / IPv6 地址判断 2021-06-22 17:28:28 +08:00
naiba c05e0658d8 🚸 IP 变更提醒增强 2021-06-22 14:05:36 +08:00
naiba 45585d13ff 🚸 优化 Agent 取 IP 2021-06-22 14:04:07 +08:00
naiba c60f5e3913 💬 统一措辞 2021-06-21 23:28:22 +08:00
naiba 145847789d 📝 说明文档润色 2021-06-21 21:54:35 +08:00
naiba 024545c702 🗑 刷新 CDN 缓存 2021-06-21 21:44:46 +08:00
naiba 8f59ee9430 ✏️ 2021-06-21 21:41:18 +08:00
naiba a9c7281af8 ✨ 优化忽略规则配置和 Agent 获取 IP 2021-06-21 21:30:42 +08:00
naiba 95b0adcba4 🐛 修复SSL证书变动状态更新 2021-06-13 01:44:08 +08:00
naiba 4f964bc8c2 🐛 领取任务超时 2021-06-12 11:51:26 +08:00
naiba 029eae8bb1 🚸 对 Agent 的信息上报增加超时(可能)增加在 Windows 上的稳定性 2021-06-12 11:37:47 +08:00
naiba 65f6a124b0 📦 为 MT7621 系列路由添加 mipsle-softfloat 编译 close #128 2021-05-28 18:22:39 +08:00
naiba 0b088b8d3b ✨ 0.7.3 通知信息脱敏 2021-05-27 21:20:00 +08:00
naiba 2d1dba9efc 添加一个冗余 DNS 服务器 2021-05-27 21:10:39 +08:00
naiba c4e5dc01e0 通知内 IP 脱敏 2021-05-27 20:48:12 +08:00
naiba c81bb41264 更新面板构建 2021-05-26 23:12:15 +08:00
naiba 7f11f5f09d Merge branch 'master' of github.com:naiba/nezha 2021-05-26 23:05:23 +08:00
naiba 0b0885d383 🔨 更新安装脚本 2021-05-26 23:05:02 +08:00
₦₳ł฿₳andEs-dese ac9a52cde1 Merge pull request #127 from Es-dese/patch-1
Update OpenWrt guide

Co-authored-by: Es-dese <71542548+Es-dese@users.noreply.github.com>
2021-05-27 02:01:42 +11:00
naiba a44b83cfa5 🔥 移除脱裤子放屁的 fetch.sh 2021-05-26 23:00:35 +08:00
Esdese c0f17410b8 Update OpenWrt guide 2021-05-26 05:18:56 -07:00
naiba 967983eed0 🚸 移除 upx,编译 arm64/mips 存在问题 2021-05-26 16:33:32 +08:00
naiba 0eaaa2b66f 🐛 修复 IPv6 取 IP 2021-05-23 13:57:43 +08:00
naiba b971fd7aa4 🐛 修复 IPv6 only 机器取 IP 问题 2021-05-23 10:25:40 +08:00
naiba 5618258e95 🚸 取 Agent IP 优化,直接分 IPv4/IPv6 访问 2021-05-22 11:28:43 +08:00
naiba 96d70fb329 🚸 优化重连超时,提高可用性 2021-05-22 10:56:30 +08:00
naiba 5739273bd3 Merge branch 'master' of github.com:naiba/nezha 2021-05-20 10:00:57 +08:00
naiba fab81a4254 ⚡️ 自动更新切换到 daocloud 加速 2021-05-20 10:00:35 +08:00
₦₳ł฿₳andErope f632702c8c Merge pull request #123 from Erope/master
解决国内镜像下载Agent缓存问题

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2021-05-13 23:54:35 +11:00
Erope 42e034bde7 指定README中sh为bash 2021-05-13 20:19:15 +08:00
Erope f49257d2b4 更新脚本安装方式 2021-05-13 20:14:14 +08:00
Erope 6a6aeb9778 增加更新安装脚本功能 2021-05-13 19:44:09 +08:00
卖女孩的小火柴 9764d04115 Update install.sh 2021-05-13 18:39:13 +08:00
Erope cbab8ed161 Merge branch 'master' of https://github.com/Erope/nezha 2021-05-13 17:55:15 +08:00
Erope 1f3cdf0cbd 获取agent版本号以便下载 2021-05-13 17:54:41 +08:00
naiba a08e3fa1f1 🔨 开启 pprof、优化 agent 2021-05-10 18:04:38 +08:00
naiba 454a8721ce ⚡️ v0.7.0 优化 Dashboard 内存占用 2021-05-10 17:30:18 +08:00
₦₳ł฿₳ 07853fbb44 Update README.md 2021-05-09 08:42:24 -07:00
₦₳ł฿₳andtechotaku b084639594 Merge pull request #122 from techotaku/service-url-improvement
Improve HTTPS certificate checking logic.

Co-authored-by: techotaku <1948179+techotaku@users.noreply.github.com>
2021-05-08 02:04:55 -07:00
Ian Li d657c9fe7c Improve HTTPS certificate checking logic. 2021-05-08 16:11:13 +08:00
naiba 93c47434ce 🏷️ 增加对 f2fs 的统计 2021-05-04 10:22:38 +08:00
naiba ca75e811ca 🐛 修复修改配置后重置为GitHub登录的问题 2021-05-02 17:21:16 +08:00
naiba aa18aaf08e 🐛 版本号丢失 2021-04-29 00:22:47 +08:00
naiba 833bd0237a remove undefind env 2021-04-29 00:11:48 +08:00
naiba 2aeb2b639f 👷 2021-04-29 00:08:42 +08:00
naiba 71598c927e 👷 取消对 mips64 的打包 2021-04-29 00:06:33 +08:00
naiba f1befbf588 ✏️ typo: npx --> upx 2021-04-28 23:53:56 +08:00
naiba 9500f7c322 🐛 fix workflow: upx license 2021-04-28 23:40:00 +08:00
naiba 115712baa6 👷 fix workflow 2021-04-28 23:37:03 +08:00
naiba 897f077e7c 👷 sudo mv upx 2021-04-28 23:31:08 +08:00
naiba 7a9f6baf59 format 2021-04-28 23:26:02 +08:00
naiba 153f8c441d Merge branch 'master' of github.com:naiba/nezha 2021-04-28 23:24:11 +08:00
naiba 44949256a0 🚸 大幅缩减 agent 二进制体积 2021-04-28 23:21:54 +08:00
₦₳ł฿₳andhyt-allen-xu 6b502f9384 Merge pull request #121 from hyt-allen-xu/master
更新 README.md 使用文档

Co-authored-by: hyt-allen-xu <73211772+hyt-allen-xu@users.noreply.github.com>
2021-04-28 07:45:38 +03:00
Allen d9407c75a6 Update README.md 2021-04-28 12:38:29 +08:00
naiba 6a712d18f5 🐛 修复硬盘信息统计问题 2021-04-26 19:13:02 +08:00
naiba dde46e49ff 🐛 agent build 2021-04-26 00:07:55 +08:00
naiba cdcd55e6fa ♻️ 优化 Mac 监控,更新依赖 2021-04-25 23:59:57 +08:00
naibaandCHN-STUDENT 3c7fe6aa3c ♻️ 优化取网卡流量及硬盘容量
Co-authored-by: CHN-STUDENT <21209416+CHN-STUDENT@users.noreply.github.com>
2021-04-25 23:46:15 +08:00
naiba 5b54575f4a 🎉 QQ交流群 2021-04-25 14:02:08 +08:00
naiba b8f6a59bc5 v0.6.6 2021-04-23 19:09:04 +08:00
naiba 54024d78dc 💄 Gitee 登录时展示使用 Gitee 登录 2021-04-23 11:07:18 +08:00
naiba 18acc0a45b 🐛 修复无法编辑已创建服务监控 2021-04-22 22:18:55 +08:00
naiba 814c371fe7 🚸 可以使指定服务器不参与服务监控 2021-04-22 21:53:31 +08:00
naiba 5580e3d22e 🚸 增大当前服务状态容错率 2021-04-22 11:17:51 +08:00
naiba 8185af768d ♻️ 重构部分代码,打印调试信息 2021-04-20 19:30:34 +08:00
naiba a50c45a3ef v0.6.1 优化CSS、服务报警、服务状态展示 2021-04-19 23:11:00 +08:00
naiba c6980be09f 🚸 dash: 展示是否已启用通知 2021-04-18 09:57:12 +08:00
naiba ef3b3a4979 ✨ dash: HTTP等服务监控的故障/恢复报警 2021-04-17 23:36:37 +08:00
₦₳ł฿₳andErope 399dcc988d Actions推送到阿里云上海仓库 && 安装脚本修改
Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2021-04-11 08:10:38 -06:00
Erope 6308cd3689 对naiba做特殊判断 2021-04-11 21:59:33 +08:00
Erope e17d0b65f5 加入转义 2021-04-11 21:13:25 +08:00
Erope 85e32ed639 刷新小写用户名JSDELIVER缓存 2021-04-11 21:03:34 +08:00
Erope f0e2ef68dc 修改阿里Docker仓库地址为naiba 2021-04-11 20:59:58 +08:00
Erope b44ccc19b0 Action加入刷新jsdelivr缓存 2021-04-11 20:47:33 +08:00
Erope dfd060e378 修改安装脚本 2021-04-11 20:41:14 +08:00
Erope 3c8df84f6c 少了个符号 2021-04-11 20:14:47 +08:00
Erope 9f1f12ad9e Action推送到阿里云上海 2021-04-11 20:13:34 +08:00
卖女孩的小火柴 2838ccb72c Merge pull request #1 from naiba/master
从上游同步
2021-04-11 19:06:48 +08:00
₦₳ł฿₳ 71200e6625 Update README.md 2021-04-07 15:13:55 +02:00
naiba bdb65b05e1 NAT64 的机器一直报 IP 变更,加忽略! 2021-04-07 21:11:59 +08:00
naibaandErope c6e272ad17 Merge pull request #119 from Erope/pull
加强了中国大陆安装时候的稳定性和安装速度

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2021-04-06 19:26:35 +08:00
Erope 3ff64a01d7 一个小错误 2021-04-06 18:11:19 +08:00
Erope 37a83ad11c 修改README对国内镜像的介绍 2021-04-06 18:06:46 +08:00
Erope ebf5a3b355 修改install.sh中错误 2021-04-06 18:02:55 +08:00
Erope 67b6bc6d79 install.sh修补 2021-04-06 17:06:25 +08:00
Erope b1d8c345d2 加强了中国大陆安装时候的稳定性和速度 2021-04-06 17:04:56 +08:00
naibaandErope ffcd74be47 Merge pull request #118 from Erope/pull
WebSocket增加Ping包 & Actions构建镜像时用户名转为小写

Co-authored-by: Erope <44471469+Erope@users.noreply.github.com>
2021-04-04 23:12:10 +08:00
Erope 905abadeb7 输出go env 2021-04-04 01:28:59 +08:00
Erope 3116b27729 修复创建docker镜像时用户名存在大写问题 2021-04-04 01:20:55 +08:00
Erope 665823dcba 没啥用的处理 2021-04-04 01:01:04 +08:00
Erope a45c743f1b 增加WebSocket的ping包 2021-04-04 00:22:50 +08:00
naiba 3efe340f87 Update README.md 2021-03-25 20:35:30 +08:00
naiba caee4e65ab 增强 IP 获取健壮性 2021-03-22 21:41:31 +08:00
naiba ef63e04e17 增强IP获取健壮性 2021-03-22 21:40:50 +08:00
naiba 1835b7742d Create LICENSE 2021-03-22 17:41:38 +08:00
naiba c1bd5580b7 Update README.md 2021-03-21 11:24:02 +08:00
naiba 3dc8e36c9f Update README.md 2021-03-21 11:23:14 +08:00
naiba b2b7a50b5c Update README.md 2021-03-21 11:20:54 +08:00
naibaandJackieSung4ev 9e8a73b89d Update README.md
Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-03-20 23:54:08 +08:00
naiba 30457b9959 更换LOGO、优化获取IP策略 2021-03-20 23:50:16 +08:00
naibaandJackieSung4ev e39f448dd2 Merge pull request #113 from JackieSung4ev/daynight
Nezha brand logo update

Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-03-20 22:51:14 +08:00
Jackie Sung c23c917ad8 Brand logo url update 2021-03-20 22:48:51 +08:00
Jackie Sung 20fe95612c Brand logo url update 2021-03-20 22:07:09 +08:00
Jackie Sung d3f6e64d08 Nezha logo update 2021-03-20 21:54:55 +08:00
naiba 350aeabf08 fix: cycle wrapped IP information 2021-03-20 14:53:10 +08:00
naiba 4cfc968cb6 fix: agent ipv6 info 2021-03-20 10:14:15 +08:00
naiba 75210d87ca improve 2021-03-20 10:05:16 +08:00
naibaandBravoyk ea2dc290b5 Merge pull request #108 from Bravoyk/master
🌈 Change background color.

Co-authored-by: Bravoyk <11516611+Bravoyk@users.noreply.github.com>
2021-03-04 14:58:23 +08:00
kkk f652d70121 change color 2021-03-04 14:17:44 +08:00
kkk 7976ef1fe8 Merge remote-tracking branch 'github/master' 2021-03-03 11:16:13 +08:00
naiba 6e8fe990fd 🔨 chore: 安装脚本 v0.4.9 2021-03-03 00:01:21 +08:00
naiba 37796937db 📝 v0.4.11 用户升级后请参考commit消息修改配置文件或重装面板
配置文件修改:github->oauth2,在github下增加 type: github 字段,将 admin 字段的值由id 变为 登录名,比如我由 20**** -> naiba
2021-03-02 23:12:23 +08:00
naiba 083a63f4f2 Merge branch 'master' of github.com:naiba/nezha 2021-03-02 23:09:00 +08:00
naiba 8062e346d5 ✨ 接入 Gitee 登录,v0.4.11 之前的用户需要更新配置文件 2021-03-02 23:08:40 +08:00
naibaandBravoyk 2e3ff7dafa Merge pull request #107 from Bravoyk/master
🌈  Modify the popup window 

Co-authored-by: Bravoyk <11516611+Bravoyk@users.noreply.github.com>
2021-03-02 15:04:42 +08:00
kkk 57cce4980d Merge remote-tracking branch 'github/master' 2021-03-02 14:46:27 +08:00
kkk 6a8a3c0a16 修改提示弹窗 2021-03-02 14:46:02 +08:00
naibaandBravoyk 16e570e876 Merge pull request #106 from Bravoyk/master
新增手动修改白天/黑夜模式

Co-authored-by: Bravoyk <11516611+Bravoyk@users.noreply.github.com>
2021-03-02 11:54:28 +08:00
kkk 573d89cca7 添加第三个主题的手动切换白黑 2021-03-02 11:47:12 +08:00
kkk dcfce53a43 Merge remote-tracking branch 'github/master' 2021-03-02 10:37:40 +08:00
kkk 8e41603bfe 添加 idea 忽略文件,修改 css 2021-03-02 10:36:42 +08:00
naiba 6dd7cd00af 🐛 fixed #105 2021-02-28 23:58:04 +08:00
naiba b516de5c1b 🚸 chore: refactor CPU model string 2021-02-27 19:24:19 +08:00
naiba 8297e2fc9f Revert "fix: correct minor typos in disk usage statistic"
This reverts commit aebc257444.
2021-02-27 18:56:59 +08:00
naiba edcfbfddfc Revert "fix: correct minor typos"
This reverts commit 31d4e1eaf9.
2021-02-27 18:07:28 +08:00
naiba 2730486422 🔨 chore: 优化安装脚本 2021-02-27 17:24:54 +08:00
naibaandCreling 02ee87b26d Merge pull request #103 from Creling/master
fix: correct minor typos in disk usage statistic

Co-authored-by: Creling <43109504+Creling@users.noreply.github.com>
2021-02-27 17:12:31 +08:00
Creling 9a0d850427 fix: correct minor typos 2021-02-27 17:03:51 +08:00
Creling 9244f354b8 fix: correct minor typos in disk usage statistic 2021-02-27 16:33:27 +08:00
naiba 061db21831 fix: hotaru 主题查看密码页 typo
还有一些文案上的调整,安装脚本更新。close #101
2021-02-25 20:00:16 +08:00
naiba 20922ec085 🔨 chore: install script 2021-02-15 19:45:46 +08:00
naiba 27d64ea9b4 📝 2021-02-13 09:42:21 +08:00
naiba 7355f7c167 🚀 2021 · 新年快乐 · 大机大利
close #89 close #90 close #91 close #92 close #93
2021-02-10 11:17:40 +08:00
naiba b1c8f52de4 ✏️ chore: typo 2021-02-09 22:42:47 +08:00
naiba 2a2d90614c 📝 chore: README 2021-02-07 09:54:28 +08:00
naiba 0f910f3884 📝 chore: format 2021-02-07 09:53:12 +08:00
naiba 74f0883eb6 🔖 chore: rename 哪吒面板 -> 哪吒监控 2021-02-07 09:51:55 +08:00
naiba aa11a6da64 💄 默认主题分组gap 2021-02-05 10:41:22 +08:00
naiba 605b020401 💄 移动端登录表单居中 2021-02-05 09:31:06 +08:00
naibaandJackieSung4ev 3e0508ba90 Merge pull request #88 from JackieSung4ev/daynight
Add password page and UI design update

Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-02-01 21:39:50 +08:00
Jackie Sung 16d8b2039e Badge version update 2021-02-01 21:37:41 +08:00
Jackie Sung 06bd06723f Badge version update 2021-02-01 21:32:20 +08:00
Jackie Sung 87ba6e0456 Add password page and UI design update 2021-02-01 21:15:36 +08:00
naiba 2104dba689 🐛 2021-01-31 19:42:56 +08:00
naiba 0ef621169a 🔨 优化一键脚本 2021-01-31 19:14:30 +08:00
naiba aa05c22ab9 ✏️ 统一命名与版本 2021-01-31 18:56:34 +08:00
naiba 769d7f55af 🔨 install script v4.0.1 2021-01-31 18:47:43 +08:00
naiba 0f6c60ff58 🔖 dashboard v0.4.4 2021-01-31 13:41:09 +08:00
naiba 9c3ac0dd84 ✨ 前台查看密码 close #24 close #41 2021-01-31 13:37:43 +08:00
naiba ae5e3e1d8d Merge branch 'master' of github.com:naiba/nezha 2021-01-30 19:06:17 +08:00
naiba a641540cda 📝 2021-01-30 19:05:41 +08:00
naibaandJackieSung4ev 2149cb0198 Merge pull request #87 from JackieSung4ev/daynight
UI design update and JavaScript files formating

Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-01-30 18:15:41 +08:00
Jackie Sung cbac48db87 UI design update and JavaScript files formating 2021-01-30 18:10:52 +08:00
naiba a5e2a691d7 🐛 fix: allow insecure connection 2021-01-30 18:07:37 +08:00
naiba 66d9ae3633 ✅ chore: purge CDN cache 2021-01-30 17:32:09 +08:00
naiba 3c76bb4651 ♻️ v4: 去掉添加 Agent ID 要求 需要更新面板 close #83 2021-01-30 17:10:51 +08:00
naiba 42b65ff79e 💄 UI: 前端优化调整 2021-01-30 11:22:59 +08:00
naiba f2a020e012 📝 chore: linguist vendored 2021-01-30 09:13:33 +08:00
naiba 654638ce8b 📝 2021-01-30 08:57:17 +08:00
naiba 15bed0f24f 🐛 fix: windows 杀子进程 2021-01-29 14:29:31 +08:00
naiba 8917c34b4a 📦 [agent v0.3.7] fix: cross compile 2021-01-29 11:59:35 +08:00
naiba fdd3a116d1 [agent v0.3.6] 🐛 fix: kill process group 2021-01-29 11:11:39 +08:00
naiba 792cf2ec36 [agent v0.3.5] 🐛 fix: exec timeout 2021-01-29 10:40:57 +08:00
naiba b75dd10866 [dashboard 0.4.1 & agent 0.3.4] 🚸 添加计划任务文案、计划任务超时 2021-01-29 09:22:24 +08:00
naibaandJackieSung4ev c2a0aaaf96 [dashboard v0.4.0] 💄 dayNight 主题更新
Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-01-29 08:53:55 +08:00
naiba e07ed5faf9 Merge pull request #86 from JackieSung4ev/daynight
Progressbar restructure and UI design update
2021-01-29 07:45:30 +08:00
Jackie Sung 5afb6fe66d JavaScript file update 2021-01-29 02:27:46 +08:00
Jackie Sung 8b8bff23ef Service page update 2021-01-29 01:00:07 +08:00
Jackie Sung a237affb8d Progressbar restructure and UI design update 2021-01-29 00:49:49 +08:00
naiba 27704b3d74 🚸 [agent v0.3.3] 优化多行及带参命令执行 2021-01-28 23:19:59 +08:00
naiba 3ec7c741b3 🔨 2021-01-27 09:27:58 +08:00
naiba ee0361a33f 📝 2021-01-26 09:16:34 +08:00
naiba 650d818174 [dashboard v0.3.12] 修复SSL证书异常数据导致重启 2021-01-26 09:12:48 +08:00
naiba 7bae4feb96 🔨 2021-01-25 14:19:31 +08:00
naiba 84fc1bb616 🚸 同一界面 2021-01-25 14:10:04 +08:00
naiba 902efd96db 🚸 fix: i686 的 32 位兼容 2021-01-25 14:09:11 +08:00
naiba e72764d431 Merge pull request #82 from naiba/dev
🚸 调整导航栏,太密
2021-01-24 21:33:34 +08:00
naiba 4b3506a485 🚸 调整导航栏,太密 2021-01-24 21:30:19 +08:00
naiba fd3bc32382 Merge pull request #81 from naiba/dev
Dev
2021-01-24 21:24:21 +08:00
naiba 6f560e5486 Merge branch 'dev' of github.com:naiba/nezha into dev 2021-01-24 21:23:43 +08:00
naiba 4c25d80470 📝 2021-01-24 21:23:39 +08:00
naibaandJackieSung4ev 04668d5f10 [dashboard v0.3.10] daynight 主题更新 + 一些重构 + 计划任务备份示例
Dev

Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-01-24 21:22:37 +08:00
naibaandJackieSung4ev bc9ef8f2df Merge pull request #79 from JackieSung4ev/daynight
Service page responsive update

Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-01-24 21:21:25 +08:00
Jackie Sung b559da528e Service page responsive update 2021-01-24 21:17:52 +08:00
naiba da281e3f12 ♻️ dayNight主题更新 + 一些重构 2021-01-24 21:17:37 +08:00
naibaandJackieSung4ev d3473ef766 Merge pull request #78 from JackieSung4ev/daynight
Update service page javaScript file

Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-01-24 19:28:28 +08:00
jackiesung4ev 70982fb585 Update service page javaScript file 2021-01-24 18:20:33 +08:00
naibaandJackieSung4ev ff48abe6b8 Merge pull request #77 from JackieSung4ev/daynight
Service page and Smooth transition update

Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-01-24 18:07:02 +08:00
jackiesung4ev 4828c2b563 Add timestamp and auto dark mode to service page 2021-01-24 18:06:19 +08:00
jackiesung4ev 037585401e Service page and Smooth transition update 2021-01-24 14:56:16 +08:00
naiba 87bc9c7a4c ✨ [dashboard v0.3.9] 手动即时触发计划任务 2021-01-24 09:41:35 +08:00
naiba e1f1ffb583 🚸 [dashboard v0.3.8] improve: 不过滤的通知 2021-01-23 18:15:11 +08:00
naiba b6d3265faf ✏️ typo 2021-01-23 17:58:04 +08:00
naiba 1da6374841 🐛 [dashboard v0.3.7] cron: 修复一些计划任务数据问题 2021-01-23 15:32:04 +08:00
naiba 99746d1af4 ⬆️ [dashboard v0.3.6] chore: purge cdn cache 2021-01-22 09:08:10 +08:00
naibaandJackieSung4ev bbe4675ee3 Merge pull request #76 from JackieSung4ev/daynight
Responsive and UI update

Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-01-22 09:03:56 +08:00
jackiesung4ev 51236da9f9 Responsive and UI update 2021-01-22 01:21:38 +08:00
naiba e92b203698 Merge branch 'master' of github.com:naiba/nezha 2021-01-21 22:56:26 +08:00
naiba fa09b9ebcd 🚸 improve: 多次点击编辑计划任务 2021-01-21 22:56:20 +08:00
naibaandilay1678 f097a5b172 Merge pull request #75 from ilay1678/master
新主题添加流量统计 close #74

Co-authored-by: ilay1678 <7021399+ilay1678@users.noreply.github.com>
2021-01-21 13:09:12 +08:00
我若为王 eaeec53607 添加流量统计 2021-01-21 12:32:52 +08:00
naiba e12b197a15 💄 dashboard v0.3.5(daynight): reformat and set brand 2021-01-21 09:39:03 +08:00
naiba 8ed5cf341e 🐛 agent v0.3.2: fix http service check 2021-01-21 09:37:29 +08:00
naiba 40b49be022 💄 improve: 优化IP变更选中状态 2021-01-20 22:57:38 +08:00
naiba b1d2396224 🎉 [dashboard v0.3.4] theme: daynight by @JackieSung4ev 2021-01-20 22:44:32 +08:00
naibaandJackieSung4ev 31c2606118 Merge pull request #73 from JackieSung4ev/daynight
Add new theme DayNight

Co-authored-by: JackieSung4ev <24974735+JackieSung4ev@users.noreply.github.com>
2021-01-20 22:40:07 +08:00
jackiesung4ev 9857bcb150 Add new theme DayNight 2021-01-20 22:22:17 +08:00
naiba bfb960b46a [dashboard 0.3.3] 添加计划任务可搜索服务器 2021-01-20 22:15:47 +08:00
naiba 56e61f2fc1 ✨ feat: 后台服务器备注 close #72 2021-01-20 19:24:59 +08:00
naiba 012eb7c877 [dash v0.3.1] restore: cpu info 2021-01-19 21:47:40 +08:00
naiba abf7fb4518 📝 2021-01-19 11:41:26 +08:00
naiba 17a02e0dee 🐛 fix: 修复 Windows Agent 启动问题,需人工下载更新 2021-01-19 11:10:32 +08:00
naiba 29cee9865e 🔊 v0.3.0 计划任务(定期备份等场景) 2021-01-19 09:59:04 +08:00
naiba b9c4782679 📝 chore: rename issue template && update workflow 2021-01-18 21:10:39 +08:00
naiba 4f94afc48e Update issue templates 2021-01-18 21:04:49 +08:00
naiba 6f155e4c51 🐛 quickfix: nil point 2021-01-18 13:45:06 +08:00
naiba 842aef50f8 🐛 2021-01-18 09:45:15 +08:00
naiba 53fda0a25f improve: ping 监控 close #68
服务状态页插入自定义代码
2021-01-18 09:42:44 +08:00
naiba 5da89b1299 📝 前端随机频繁掉线的说明 2021-01-18 00:21:22 +08:00
naiba 0513887375 ⚡️ refactor: 将排序的服务器列表的锁拆分出来 2021-01-17 22:18:36 +08:00
naiba 3f4b72676b ⚡️ refactor: 小幅提升性能 2021-01-17 22:05:59 +08:00
naiba daf163a72b 🐛 fix: 取历史SSL采样导致频繁重启 2021-01-17 18:27:29 +08:00
naiba 631e3668a5 fix: 时区问题 2021-01-17 15:56:02 +08:00
naiba bbe848465d 🚸 improve: 优化SSL证书提醒 close #67 2021-01-17 15:43:34 +08:00
naiba 9853f8741a Update README.md 2021-01-17 11:59:58 +08:00
naiba 32d13d2978 🗑 chore: cdn cache 2021-01-17 11:53:43 +08:00
naiba 150fbf2237 dashboard v0.2.4 优化当前状态数据 2021-01-17 11:44:50 +08:00
naiba 2aa1cc806e Update README.md 2021-01-16 21:41:31 +08:00
naiba ba75954e40 ⚡️ improve: 服务监控相关优化 2021-01-16 18:04:47 +08:00
naiba 8175209717 💄 chore: 服务监控相关的前端优化 2021-01-16 15:36:00 +08:00
naiba f13cfad075 🔥 chore: clean code 2021-01-16 15:25:19 +08:00
naiba 031b3d4068 🐛 fix: 修复一直报警SSL证书问题 2021-01-16 15:08:33 +08:00
naiba 4118b57e2c ⚡️ improve: 删除服务时删除历史数据 2021-01-16 15:05:35 +08:00
naiba 253dc21088 🐛 fix: 修复双栈IP只能获取到v6的问题 close #61 2021-01-16 14:51:33 +08:00
naiba 8fa4a36d03 🚸 improve: 很多更新,看变更日志 close #58 2021-01-16 14:20:42 +08:00
naiba 27fcfbf76a 🚸 release: v0.2.1 2021-01-16 14:11:51 +08:00
naibaandilay1678 494818a6dc Merge pull request #59 from ilay1678/master
优化服务监控布局

Co-authored-by: ilay1678 <7021399+ilay1678@users.noreply.github.com>
2021-01-16 13:56:05 +08:00
我若为王 f412202989 优化服务监控布局 2021-01-16 13:17:11 +08:00
naiba 92a273159e Merge branch 'master' of github.com:naiba/nezha 2021-01-16 11:42:18 +08:00
naiba 330b1f9e6a 🚸 improve: 优化导航栏 close #55 close #56 2021-01-16 11:42:12 +08:00
naibaandilay1678 557699a28d Merge pull request #57 from ilay1678/master
修复详情面板透明的bug

Co-authored-by: ilay1678 <7021399+ilay1678@users.noreply.github.com>
2021-01-16 11:39:34 +08:00
我若为王 7de02ade39 修复详情面板透明的bug 2021-01-16 11:27:37 +08:00
naiba 9702312d00 🚸 improve: 优化agent取双栈IP、服务状态页面展示策略 2021-01-16 11:23:42 +08:00
naiba 8cc5d1c32b 💥 v2.0 必须更新面板,新增服务监控 2021-01-16 00:45:49 +08:00
naiba 97fc5d790a ✨ feat: ip change notification close #31 2021-01-13 22:30:28 +08:00
naibaandilay1678 02bd23d705 Merge pull request #54 from ilay1678/master
调整状态卡片上下空白高度

Co-authored-by: ilay1678 <7021399+ilay1678@users.noreply.github.com>
2021-01-12 18:06:18 +08:00
我若为王 7e1afc1378 调整状态卡片上下空白高度 2021-01-12 18:00:51 +08:00
naibaandilay1678 6edca1120e Merge pull request #53 from ilay1678/master
添加标签功能,默认主题可根据标签分组 #32

Co-authored-by: ilay1678 <7021399+ilay1678@users.noreply.github.com>
2021-01-12 16:41:37 +08:00
我若为王 8df02c0895 修复数据不更新的问题、分组显示改为折叠菜单 2021-01-12 16:34:11 +08:00
我若为王 5be775a430 添加标签功能 2021-01-12 14:09:25 +08:00
naiba 1365f1f980 fix: 隐藏了ID没法添加Agent 2021-01-09 09:34:27 +08:00
naiba 449d0a6dcd 🚸 improve: 优化修改服务器导致的主机信息丢失 2021-01-08 21:21:06 +08:00
naiba 2b585433dc Merge branch 'master' of github.com:naiba/nezha 2021-01-08 21:04:57 +08:00
naiba 1d2c81ead2 ✨ feat: 展示排序 close #29 2021-01-08 21:04:50 +08:00
naiba 88f607cbd5 来自 @haitau 的 tg 通知示例 2021-01-08 18:28:55 +08:00
naiba de9d781326 🐛 fix: 旧采样点清理及报警策略优化 2021-01-06 21:20:02 +08:00
naiba 10d7cdf106 🐛 fix: divide by zero 2021-01-06 12:46:13 +08:00
naiba 812a4e6e61 📝 2021-01-06 12:05:32 +08:00
naiba 515b3979f1 🐛 fix: 通知延时|更新面板! 2021-01-06 11:26:30 +08:00
naiba 2d34e2feeb 🚸 improve: 报警优化 2021-01-06 09:35:04 +08:00
naiba 22ea1132bd 🚸 improve: live threshold 2021-01-04 10:51:57 +08:00
naiba aaf9209a5c 🔥 chore: unnecessary code 2021-01-03 21:17:39 +08:00
naiba 41d744e191 🐛 fix(notification): close #47
query escape && json encode
2021-01-03 21:11:00 +08:00
naiba 1b091b591e 🎨 refactor: notification 2020-12-30 21:28:57 +08:00
naiba c86603d421 Update README.md 2020-12-30 18:30:20 +08:00
naiba d42773c12a chore: format 2020-12-30 13:54:37 +08:00
naiba 4ce32e2082 📝 chore: format 2020-12-30 13:51:42 +08:00
naibaandiLay1678 3d3863af8d Merge pull request #45 from iLay1678/master
自定义版权去除jq引用,添加wxpush示例

Co-authored-by: iLay1678 < 7021399+iLay1678@users.noreply.github.com>
2020-12-30 13:40:07 +08:00
我若为王 5c7b8bea86 Update README.md 2020-12-30 13:08:32 +08:00
我若为王 3477629139 Update README.md 2020-12-30 13:07:33 +08:00
我若为王 35aa61bd88 Update README.md 2020-12-30 13:06:37 +08:00
我若为王 18c65d1d82 Update README.md 2020-12-30 13:03:21 +08:00
naiba c04d5a87b4 📝 chore: 去掉群号 2020-12-28 12:11:51 +08:00
naiba 1c693ec89a 🎨 chore: 增强脚本功能 2020-12-26 17:58:10 +08:00
naiba b1119f62d9 💄 improve(ui): progress bar and percent 2020-12-25 20:35:06 +08:00
naiba 8f6ba31e00 🚸 增强面板自定义CSS功能 2020-12-24 09:54:17 +08:00
naiba 362326ff4f 💄 improve: 优化前端在线规则 2020-12-23 20:22:04 +08:00
naiba 22b927df39 🐛 修复部分用户cdn取到的js不一致 2020-12-22 21:54:04 +08:00
naiba cbbca9ec97 📝 2020-12-22 09:34:16 +08:00
naiba 3e3bfcbda4 🐛 fix(alert): 跳过未启用规则 2020-12-21 23:56:08 +08:00
naiba d81688a6d7 🐛 fix(alert): 多规则同时满足 2020-12-21 22:51:23 +08:00
naiba 4f94a76fb0 🐛 fix: 旧数据清理bug,一直报警 2020-12-21 21:56:58 +08:00
naiba 272ed5d00c 🐛 fix(alertData): 旧数据保留尾部而不是头部 2020-12-21 20:10:07 +08:00
naiba 64e4a3defe optimize 2020-12-21 19:21:00 +08:00
naiba c1fbb9cf4c ✨ feat(agent): ARM 实验性支持-未测试 2020-12-21 19:14:50 +08:00
naiba e0341cd287 🐛 fix(alert): 添加报警规则 2020-12-21 16:34:21 +08:00
naiba 9e7c4a1685 💄 improve(ui): progress bar 2020-12-20 23:15:57 +08:00
naiba 4b1598b6c4 🐛 fix(template): 自定义CSS 2020-12-20 22:09:53 +08:00
naiba dfddfdf79d Revert "📝"
This reverts commit d7a5e1fc5e.
2020-12-20 22:09:19 +08:00
naiba 3ae19340ad 📝 2020-12-20 22:05:06 +08:00
naiba 045372be3c 💄 improve(ui): hotaru 主题底部进度条数据融合 2020-12-20 21:47:12 +08:00
naiba 8c27dd8ee1 🎨 优化进度条颜色、报警策略 2020-12-20 21:19:27 +08:00
naiba 9aac2e2579 🚸 agent: exit code 2020-12-20 13:03:03 +08:00
naiba e1a0c3a7c3 🍻 报警通知上线 2020-12-20 12:18:27 +08:00
naiba c7158a1458 ✨ up(agent): 不间断上报信息为报警作准备 2020-12-19 23:27:59 +08:00
naiba 62fdf18fb7 up(notification): 添加修改删除通知、报警规则 2020-12-19 23:11:16 +08:00
naiba 82adcf8fd3 ✨ feat(notification): 添加通知方式 2020-12-19 22:14:36 +08:00
naiba 8bed298f84 📝 doc: update 2020-12-19 18:41:52 +08:00
naiba fbfc7a2e67 📝 update 2020-12-19 18:38:28 +08:00
naiba b0cec36bb5 📝 update 2020-12-19 18:32:45 +08:00
naiba 988c0cd1f1 📝 update 2020-12-19 16:29:57 +08:00
naiba 915875b24d 🚸 update cdn url 2020-12-19 13:07:44 +08:00
naiba d373839f79 🐛 fix: cpu 2020-12-19 12:52:28 +08:00
naiba ef7009e5bb 💄 improve: done #10 进度条 2020-12-19 12:43:03 +08:00
naiba 2e4b8ae734 feat(theme): 自定义主题 2020-12-19 10:57:10 +08:00
naiba a7fba1c80d 💩 2020-12-13 11:53:04 +08:00
naiba 5e7eb58fd9 fix 2020-12-13 11:46:37 +08:00
naiba 5b7ae83d6f 💚 remove unsupported by older go version 2020-12-13 11:43:55 +08:00
naiba 558b05ef79 💚 remove unsupported by older go version 2020-12-13 11:42:34 +08:00
naiba d71d3818e1 Revert "💩"
This reverts commit 4d4e332a55.
2020-12-13 11:37:46 +08:00
naiba a61a4899ff 💩 2020-12-13 11:28:06 +08:00
naiba 63456e4c09 Revert "测试对旧版 windows 支持的编译"
This reverts commit c8a1133332.
2020-12-13 11:25:30 +08:00
naiba 16f3f28835 测试对旧版 windows 支持的编译 2020-12-13 11:22:29 +08:00
naiba 87f7ce0b1c 💚 fix ci 2020-12-13 11:18:42 +08:00
naiba e76342dfbb 💚 fix 2020-12-13 11:15:08 +08:00
naiba aeb087a538 💚 fix ci 2020-12-13 11:13:19 +08:00
naiba dc8bd1e362 💚 set git account's default identity 2020-12-13 11:09:52 +08:00
naiba 356a472621 💚 fix dirty state 2020-12-13 11:06:15 +08:00
naiba 3d555a6429 测试 go mod 兼容 2020-12-13 11:01:43 +08:00
naiba a3bbfe4467 💩 由于 go mod 依赖错综复杂,停止对 Windows 旧版本的支持计划 2020-12-13 02:11:24 +08:00
naiba c7d07e556d 最终测试 gomod 兼容 2020-12-13 02:06:28 +08:00
naiba 6994dd8856 修正 go mod 不兼容 2020-12-13 02:00:41 +08:00
naiba 5fd2a25bed downgrade gopsutil 2020-12-13 01:55:35 +08:00
naiba f24a77e499 fix 2020-12-13 01:48:01 +08:00
naiba 9021cc98e2 fix workflow 2020-12-13 01:42:08 +08:00
naiba ffea1b4c5e test fix 2020-12-13 01:40:30 +08:00
naiba a2db2d18e5 🐛 fix missed package 2020-12-13 01:36:25 +08:00
naiba 7fda3e7923 🎨 fork go-github-self-update 2020-12-13 01:31:22 +08:00
naiba eae55c8b85 修复 go mod 不兼容 2020-12-13 00:43:25 +08:00
naiba 6ce57f6412 💚 fix go get 2020-12-13 00:35:17 +08:00
naiba 28e7173877 fix 2020-12-13 00:30:08 +08:00
naiba 8b58299865 🐛 fix go releaser 2020-12-13 00:27:42 +08:00
naiba 0bef2afe1f 🐛 fix workflow 2020-12-13 00:23:28 +08:00
naiba ccae26454a 恢复对部分 Windows 旧版本支持
Windows Vista, Windows Server 2008,  XP, Windows Server 2003 的 x64/x86
2020-12-12 23:48:34 +08:00
naiba a4cf18e57f 🚸 improve 2020-12-10 06:46:31 +08:00
naiba abf1abe002 🚸 一键卸载命令 2020-12-10 06:28:37 +08:00
naiba 6bcf4a90bd ⚡️ improve download speed from China 2020-12-10 06:18:16 +08:00
naibaandguoyongchang 3aad5847ad Merge pull request #14 from guoyongchang/patch-1
服务器位于大陆时的一些问题及解决方案

Co-authored-by: guoyongchang <guoyongchang@chanyi.vip>
2020-12-09 23:18:17 +08:00
guoyongchang f28fe0836f Update README.md 2020-12-09 23:03:04 +08:00
naiba 2e02deb11e 💄 improve 2020-12-09 20:26:14 +08:00
naiba bae8e22b62 ⬆️ replace cdn 2020-12-09 19:10:28 +08:00
naiba 19dde87567 ✨ add theme hotaru 2020-12-09 19:05:40 +08:00
naiba 5bb5e32758 🐛 fix #9 2020-12-09 10:27:00 +08:00
naiba 7e0e947692 📝 doc 2020-12-08 10:38:45 +08:00
naiba 2e4b3c58a5 📝 doc 2020-12-08 09:51:53 +08:00
naiba f012505ca8 🔒 hide secret in ws messages 2020-12-07 20:47:13 +08:00
naiba 03925b795e 💄 improve(ui) 2020-12-07 13:18:28 +08:00
naiba 105f7bcaed improve(ui): swap progress bar 2020-12-07 12:57:42 +08:00
naiba ae25308698 📝 update doc 2020-12-07 12:55:32 +08:00
naiba 0c5656185c 📝 doc 2020-12-07 12:52:46 +08:00
naiba f068cac991 💄 improve(ui): progress bar color 2020-12-07 12:48:38 +08:00
naiba db8702ad77 improve(shell): add tar check 2020-12-07 12:10:42 +08:00
naiba 5a207e9502 fix typo 2020-12-07 11:50:27 +08:00
naiba 7531bfae06 🐛 fix(shell) 2020-12-07 11:39:03 +08:00
naiba 1c9d93e0d9 ✏️ fix(typo) 2020-12-07 11:34:44 +08:00
naiba ca5217de35 ✏️ fix(shell): typo 2020-12-07 11:15:07 +08:00
naiba 316634b15d feat(shell): 支持自定义 面板/RPC 端口 2020-12-07 09:38:10 +08:00
naiba 7c65b654eb 📝 update 2020-12-02 11:46:31 +08:00
naiba 04bd4be2f3 📝 #5 update doc 2020-12-02 10:04:06 +08:00
naiba 221963e8ca 💩 fix self update, go-update could not restart it-self 2020-11-30 17:05:36 +08:00
naiba c7d54888da improve 2020-11-30 14:44:01 +08:00
naiba 3dc0d7ae69 ♻️ register when upgrade 2020-11-30 14:24:00 +08:00
naiba 773d14b914 fix 2020-11-30 14:15:22 +08:00
naiba 50db961126 fix 2020-11-30 14:13:03 +08:00
naiba eb9d1209bb ⚡️ fix update delay 2020-11-30 13:49:31 +08:00
naiba 33a8118be7 🐛 fix auto update 2020-11-30 13:22:58 +08:00
naiba 10c7844c70 🎨 inject version code 2020-11-30 12:45:51 +08:00
naiba 44c15c08c6 📝 print dashboard endpoint 2020-11-30 11:22:44 +08:00
naiba 0187bbf670 ♿️ 2020-11-30 11:15:34 +08:00
naiba 1695cc841d ✅ 一键脚本测试 centos 8 2020-11-30 10:52:25 +08:00
naiba ccb7e95ed2 ✨ self update 2020-11-30 00:07:27 +08:00
naiba 85acd707eb test 2020-11-29 23:07:31 +08:00
naiba d59db511f5 🔨 rename 2020-11-29 22:38:17 +08:00
naiba 2fbd9645b8 💚 only branches 2020-11-29 22:27:33 +08:00
naiba d365e9caa8 ✏️ markdown link typo 2020-11-29 22:23:00 +08:00
naiba 8d2933ac21 ✨ 一键脚本 2020-11-29 22:17:40 +08:00
naiba f770c0a62a dep::gopsutil bump version to v3 2020-11-21 22:07:34 +08:00
naiba 56a22f2a0f 📦 Migrate to ghcr.io 2020-11-12 11:12:30 +08:00
naiba ad5c96faa9 🗑 remove env 2020-11-11 22:32:50 +08:00
naiba a8d8e3721f 👷 Update workflow 2020-11-11 22:29:03 +08:00
naiba 3625c24aa1 👷 docker build workflow 2020-11-11 22:21:16 +08:00
naiba a4611beaec 🚚 from p14yground to naiba 2020-11-11 10:07:45 +08:00
naiba 662a46d165 🚸 always auto restart 2020-11-09 12:02:06 +08:00
naiba e31c3aa325 ⚡️ optimize docker layer 2020-11-06 21:25:17 +08:00
naiba ba390479ff 🐛 fix dashboard dockerfile 2020-11-06 21:00:42 +08:00
naiba 666480f92e 🎨 CPU display 2020-11-06 20:56:46 +08:00
naiba bd8925097e ✨ 容器化部署 2020-11-06 20:51:23 +08:00
naiba d201369451 🎨 build version 2020-10-29 11:20:32 +08:00
naiba 7cd3b28403 🐛 resource 2020-10-29 00:14:31 +08:00
naiba 984bffff74 ✨ docker 2020-10-29 00:00:58 +08:00
naiba 23a6259060 🐛 window.location.protocol 2020-10-25 09:57:50 +08:00
naiba a38c5e3649 📝 guide link 2020-10-25 09:55:54 +08:00
naiba ba0c5b4223 🚚 add pb code 2020-10-24 21:46:07 +08:00
naiba 8b4bd0e40e 🎨 custom http port 2020-10-24 21:44:03 +08:00
naiba f2c304e396 🎨 improve offline server detect 2020-10-24 21:29:05 +08:00
奶爸 024a11bcd0 多管理员 2020-03-22 22:28:25 +08:00
奶爸 22ebad36ab show offline node 2020-03-22 21:43:37 +08:00
奶爸 bc2ff97b5b Update readme 2020-03-22 20:57:23 +08:00
奶爸 063e551248 修改删除节点 2020-03-22 20:55:27 +08:00
奶爸 d3c23f86c1 fix: md error 2020-02-28 10:55:32 +08:00
奶爸 89e6a041a4 update: readme 2020-02-28 10:53:25 +08:00
奶爸 157dd6286e fix: close connection when reconnect 2020-02-09 22:04:59 +08:00
奶爸 f2080ed795 持久化Token 2019-12-20 23:58:09 +08:00
奶爸 ce3c5139f7 修复内存数据更新 2019-12-13 17:56:14 +08:00
奶爸 1eced419ea 优化流量、硬盘上报 2019-12-13 14:51:51 +08:00
奶爸 435be05fc0 优化监控上传 2019-12-11 21:50:49 +08:00
奶爸 c56800029e 用更友好的交互替换alert 2019-12-11 18:03:49 +08:00
奶爸 a27862f0a6 移动端优化 2019-12-11 16:20:33 +08:00
奶爸 2ed8ea91d9 修复直接100%的问题 2019-12-11 15:56:47 +08:00
奶爸 5a8750b065 修复添加服务器死锁 2019-12-11 14:41:12 +08:00
奶爸 3ede8b18a9 修复国旗展示 2019-12-11 14:10:48 +08:00
奶爸 654905236b systemd 文件 2019-12-11 13:34:22 +08:00
奶爸 2531ce9864 展示系统图标 2019-12-10 18:11:15 +08:00
奶爸 6f92e6ea05 优化 Websocket 连接 2019-12-10 18:05:02 +08:00
奶爸 920737ff0c Websocket 实时拉取 2019-12-10 17:57:57 +08:00
奶爸 d5f2fcd85b 修改分隔符优化排版 2019-12-10 15:49:06 +08:00
奶爸 08eea4299f 引入Vue 2019-12-10 15:33:05 +08:00
奶爸 f408066509 展示监控信息 2019-12-09 23:45:23 +08:00
奶爸 db364099fb 展示服务器列表 2019-12-09 18:14:31 +08:00
奶爸 f7d7e9ef49 客户端授权验证 2019-12-09 16:02:49 +08:00
奶爸 dc2ff60f5a 客户端cmd flag 2019-12-08 23:49:38 +08:00
奶爸 1311480d41 添加服务器 & 展示服务器 2019-12-08 23:18:29 +08:00
奶爸 f2f840de4a Web 服务 2019-12-08 16:59:58 +08:00
奶爸 b76ea0b4d3 gRPC 上报系统信息 2019-12-07 18:14:40 +08:00
奶爸 20dc595fdd protobuf 代码生成 2019-12-05 23:42:20 +08:00
奶爸 21af0e207f gRPC with toek demo 2019-12-05 22:36:58 +08:00
奶爸 49f5840097 开发计划 2019-12-02 23:29:00 +08:00
奶爸 a5e0f00487 uptime & bootTime 2019-12-02 23:28:30 +08:00
奶爸 041d9cd85b Get system info 2019-12-02 22:57:14 +08:00
奶爸 71dc3d3f60 Update README.md 2019-12-01 23:36:23 +08:00
奶爸 70dc02566d Init arch 2019-11-09 23:15:15 +08:00
奶爸 ded062e2c0 Error example 2019-11-09 12:14:29 +08:00
奶爸 94b0f23d22 Ping test get output 2019-11-09 12:13:38 +08:00
奶爸 6922138da7 Init go mod 2019-11-09 12:05:53 +08:00
奶爸 392eb3aee3 Initial commit 2019-11-09 12:01:46 +08:00
840 changed files with 103751 additions and 2376 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
go-dependencies:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
github-actions:
patterns:
- "*"
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

-134
View File
@@ -1,134 +0,0 @@
import os
import time
import requests
import hashlib
from github import Github
def get_github_latest_release():
g = Github()
repo = g.get_repo("nezhahq/nezha")
release = repo.get_latest_release()
if release:
print(f"Latest release tag is: {release.tag_name}")
print(f"Latest release info is: {release.body}")
files = []
for asset in release.get_assets():
url = asset.browser_download_url
name = asset.name
response = requests.get(url)
if response.status_code == 200:
with open(name, 'wb') as f:
f.write(response.content)
print(f"Downloaded {name}")
else:
print(f"Failed to download {name}")
file_abs_path = get_abs_path(asset.name)
files.append(file_abs_path)
sync_to_gitee(release.tag_name, release.body, files)
else:
print("No releases found.")
def delete_gitee_releases(latest_id, client, uri, token):
get_data = {
'access_token': token
}
release_info = []
release_response = client.get(uri, json=get_data)
if release_response.status_code == 200:
release_info = release_response.json()
else:
print(
f"Request failed with status code {release_response.status_code}")
release_ids = []
for block in release_info:
if 'id' in block:
release_ids.append(block['id'])
print(f'Current release ids: {release_ids}')
release_ids.remove(latest_id)
for id in release_ids:
release_uri = f"{uri}/{id}"
delete_data = {
'access_token': token
}
delete_response = client.delete(release_uri, json=delete_data)
if delete_response.status_code == 204:
print(f'Successfully deleted release #{id}.')
else:
raise ValueError(
f"Request failed with status code {delete_response.status_code}")
def sync_to_gitee(tag: str, body: str, files: slice):
release_id = ""
owner = "naibahq"
repo = "nezha"
release_api_uri = f"https://gitee.com/api/v5/repos/{owner}/{repo}/releases"
api_client = requests.Session()
api_client.headers.update({
'Accept': 'application/json',
'Content-Type': 'application/json'
})
access_token = os.environ['GITEE_TOKEN']
release_data = {
'access_token': access_token,
'tag_name': tag,
'name': tag,
'body': body,
'prerelease': False,
'target_commitish': 'master'
}
release_api_response = api_client.post(release_api_uri, json=release_data)
if release_api_response.status_code == 201:
release_info = release_api_response.json()
release_id = release_info.get('id')
else:
print(
f"Request failed with status code {release_api_response.status_code}")
print(f"Gitee release id: {release_id}")
asset_api_uri = f"{release_api_uri}/{release_id}/attach_files"
for file_path in files:
success = False
while not success:
files = {
'file': open(file_path, 'rb')
}
asset_api_response = requests.post(
asset_api_uri, params={'access_token': access_token}, files=files)
if asset_api_response.status_code == 201:
asset_info = asset_api_response.json()
asset_name = asset_info.get('name')
print(f"Successfully uploaded {asset_name}!")
success = True
else:
print(
f"Request failed with status code {asset_api_response.status_code}")
# 仅保留最新 Release 以防超出 Gitee 仓库配额
try:
delete_gitee_releases(release_id, api_client, release_api_uri, access_token)
except ValueError as e:
print(e)
api_client.close()
print("Sync is completed!")
def get_abs_path(path: str):
wd = os.getcwd()
return os.path.join(wd, path)
get_github_latest_release()
-75
View File
@@ -1,75 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '15 20 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v4
with:
go-version-file: go.mod
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
-29
View File
@@ -1,29 +0,0 @@
name: Contributors
on:
push:
branches: [master]
jobs:
contributors:
runs-on: ubuntu-latest
steps:
- name: Generate Contributors Images
uses: jaywcjlove/github-action-contributors@main
id: contributors
with:
filter-author: (renovate\[bot\]|renovate-bot|dependabot\[bot\])
hideName: 'false' # Hide names in htmlTable
avatarSize: 50 # Set the avatar size.
truncate: 6
avatarMargin: 8
- name: Modify htmlTable README.md
uses: jaywcjlove/github-action-modify-file-content@main
with:
message: update contributors[no ci]
token: ${{ secrets.NAIBA_PAT }}
openDelimiter: '<!--GAMFC_DELIMITER-->'
closeDelimiter: '<!--GAMFC_DELIMITER_END-->'
path: README.md
body: '${{steps.contributors.outputs.htmlList}}'
+69 -21
View File
@@ -7,6 +7,9 @@ on:
branches:
- dev
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
strategy:
@@ -15,24 +18,25 @@ jobs:
goos: [linux, windows]
goarch: [amd64]
include:
- goos: linux
goarch: s390x
- goos: linux
goarch: arm64
- goos: linux
goarch: s390x
name: Build artifacts
runs-on: ubuntu-latest
container:
image: goreleaser/goreleaser-cross:v1.24
image: goreleaser/goreleaser-cross:v1.26
steps:
- run: |
- name: Install dependencies
run: |
apt update && apt install unzip curl -y
wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq
chmod +x /usr/bin/yq
- run: git config --global --add safe.directory /__w/nezha/nezha
- uses: actions/checkout@v4
- uses: actions/checkout@v7.0.1
- name: Prepare frontends' dists
run: |
@@ -46,18 +50,56 @@ jobs:
wget -qO pkg/geoip/geoip.db https://ipinfo.io/data/free/country.mmdb?token=${IPINFO_TOKEN}
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version: "1.24.x"
go-version: "^1.26.10"
- name: generate swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@latest
swag init --pd -d . -g ./cmd/dashboard/main.go -o ./cmd/dashboard/docs --parseGoList=false
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
go mod tidy
- name: Cache zstd for s390x
if: matrix.goarch == 's390x'
id: cache-zstd
uses: actions/cache@v6
with:
path: /tmp/zstd-s390x
key: zstd-s390x-v1.5.7
- name: Build zstd for s390x
if: matrix.goarch == 's390x'
run: |
# gozstd doesn't ship prebuilt libzstd for s390x, build it manually
go mod vendor
if [ -f /tmp/zstd-s390x/libzstd.a ]; then
echo "Using cached libzstd.a"
else
# Clone zstd source and build for s390x
ZSTD_VERSION=v1.5.7
git clone --depth 1 --branch ${ZSTD_VERSION} https://github.com/facebook/zstd /tmp/zstd
cd /tmp/zstd/lib
CC=s390x-linux-gnu-gcc \
CXX=s390x-linux-gnu-g++ \
AR=s390x-linux-gnu-ar \
ZSTD_LEGACY_SUPPORT=0 \
make clean libzstd.a
mkdir -p /tmp/zstd-s390x
cp libzstd.a /tmp/zstd-s390x/libzstd.a
fi
# Copy to vendor directory
GOZSTD_VENDOR=${GITHUB_WORKSPACE}/vendor/github.com/valyala/gozstd
cp /tmp/zstd-s390x/libzstd.a ${GOZSTD_VENDOR}/libzstd_linux_s390x.a
# Create the Go file to link the library
printf '//go:build linux && s390x\n\npackage gozstd\n\n/*\n#cgo LDFLAGS: ${SRCDIR}/libzstd_linux_s390x.a\n*/\nimport "C"\n' > ${GOZSTD_VENDOR}/libzstd_linux_s390x.go
- name: Build with tag
if: contains(github.ref, 'refs/tags/')
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
@@ -69,7 +111,7 @@ jobs:
- name: Build snapshot
if: contains(github.ref, 'refs/tags/') == false
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
@@ -80,7 +122,7 @@ jobs:
args: build --single-target --clean --skip=validate --snapshot
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: dashboard-${{ matrix.goos }}-${{ matrix.goarch }}
path: |
@@ -93,7 +135,7 @@ jobs:
name: Release
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: ./assets
@@ -107,10 +149,10 @@ jobs:
done
- name: Release
uses: ncipollo/release-action@v1
uses: softprops/action-gh-release@v3
with:
artifacts: "assets/*/*/*.zip"
generateReleaseNotes: true
files: "assets/*/*/*.zip"
generate_release_notes: true
- name: Purge jsdelivr cache
run: |
@@ -131,6 +173,7 @@ jobs:
GH_DEBUG: api
run: |
gh workflow run sync-release.yml
gh workflow run sync-release-atomgit.yml
release-docker:
runs-on: ubuntu-latest
@@ -138,10 +181,10 @@ jobs:
needs: build
name: Release Docker images
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7.0.1
- name: Download artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: ./assets
@@ -177,10 +220,10 @@ jobs:
password: ${{ secrets.ALI_PAT }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Set up image name
run: |
@@ -195,11 +238,16 @@ jobs:
- name: Build dasbboard image And Push with tag
if: contains(github.ref, 'refs/tags/')
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/s390x
# Aliyun Container Registry rejects BuildKit attestation manifests
# (application/vnd.oci.empty.v1+json). Both registries share this
# multi-platform push, so publish a plain OCI image index here.
provenance: false
sbom: false
push: true
tags: |
${{ steps.image-name.outputs.GHCR_IMAGE_NAME }}:latest
@@ -209,7 +257,7 @@ jobs:
- name: Build dasbboard image And Push snapshot
if: contains(github.ref, 'refs/tags/') == false
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
-16
View File
@@ -1,16 +0,0 @@
name: Sync
on:
push:
branches: [master]
jobs:
sync-to-jihulab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: adambirds/sync-github-to-gitlab-action@v1.1.0
with:
destination_repository: git@gitee.com:naibahq/nezha.git
destination_branch_name: master
destination_ssh_key: ${{ secrets.GITEE_SSH_KEY }}
-17
View File
@@ -1,17 +0,0 @@
name: Sync Release to Gitee
on:
workflow_dispatch:
jobs:
sync-release-to-gitee:
runs-on: ubuntu-latest
timeout-minutes: 120
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Sync to Gitee
run: |
pip3 install PyGitHub
python3 .github/sync.py
+127 -25
View File
@@ -2,49 +2,151 @@ name: Run Tests
on:
push:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "resource/**"
- ".github/workflows/test.yml"
branches:
- master
pull_request:
branches:
- master
merge_group:
permissions:
contents: read
concurrency:
group: nezha-quality-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
name: Ordinary tests and build (${{ matrix.os }})
strategy:
fail-fast: true
fail-fast: false
matrix:
os: [ubuntu, windows, macos]
runs-on: ${{ matrix.os }}-latest
env:
GO111MODULE: on
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v7.0.1
with:
go-version: "1.24.x"
persist-credentials: false
- uses: actions/setup-go@v7
with:
go-version: "^1.26.10"
cache: false
- name: generate swagger docs
- name: Generate Swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@latest
go install github.com/swaggo/swag/cmd/swag@v1.16.6
touch ./cmd/dashboard/user-dist/a
touch ./cmd/dashboard/admin-dist/a
swag init --pd -d . -g ./cmd/dashboard/main.go -o ./cmd/dashboard/docs --parseGoList=false
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
- name: Unit test
run: |
go test -v ./...
run: go test -mod=readonly -count=1 ./...
- name: Build test
- name: Build dashboard
run: go build -v ./cmd/dashboard
- name: Run Gosec Security Scanner
if: runner.os == 'Linux'
uses: securego/gosec@master
linux-race-quality:
name: Linux race and quality
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v7.0.1
with:
args: --exclude=G104,G402,G115,G203 ./...
persist-credentials: false
- uses: actions/setup-go@v7
with:
go-version: "^1.26.10"
cache: false
- name: Generate Swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@v1.16.6
touch ./cmd/dashboard/user-dist/a
touch ./cmd/dashboard/admin-dist/a
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
- name: Race and shuffle tests
run: go test -mod=readonly -race -shuffle=on -count=1 ./...
- name: Vet
run: go vet ./...
- name: Check formatting
shell: bash
run: test -z "$(git ls-files -co --exclude-standard '*.go' -z | xargs -0 gofmt -l)"
- name: Build dashboard
run: go build ./cmd/dashboard
- name: Run Gosec Security Scanner
shell: bash
env:
GOTOOLCHAIN: auto
run: |
go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1
gosec --exclude=G104,G115,G117,G203,G402,G703,G704 ./...
agentcompat-stress:
name: Linux agent compatibility stress
runs-on: ubuntu-24.04
timeout-minutes: 75
steps:
- name: Checkout Nezha revision
uses: actions/checkout@v7.0.1
with:
path: nezha
persist-credentials: false
- name: Checkout Agent repository
uses: actions/checkout@v7.0.1
with:
repository: nezhahq/agent
path: agent
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "^1.26.10"
cache: false
- name: Prepare Dashboard build inputs
working-directory: nezha
run: |
go install github.com/swaggo/swag/cmd/swag@v1.16.6
mkdir -p cmd/dashboard/user-dist cmd/dashboard/admin-dist
printf 'placeholder\n' > cmd/dashboard/user-dist/placeholder.txt
printf 'placeholder\n' > cmd/dashboard/admin-dist/placeholder.txt
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
- name: Require named stress test
working-directory: nezha
run: go test -mod=readonly -tags=agentcompat -list '^TestStressPRFullEightAgentExactlyOnce$' ./integration/agentcompat/internal/scenario | grep -Fx 'TestStressPRFullEightAgentExactlyOnce'
- name: Run PR-full agent compatibility stress
working-directory: nezha
env:
AGENTCOMPAT_NEZHA_SOURCE: ${{ github.workspace }}/nezha
AGENTCOMPAT_AGENT_SOURCE: ${{ github.workspace }}/agent
run: go test -mod=readonly -tags=agentcompat -run '^TestStressPRFullEightAgentExactlyOnce$' -count=1 -v ./integration/agentcompat/internal/scenario
nezha-quality-required:
name: nezha-quality-required
if: ${{ always() }}
needs:
- tests
- linux-race-quality
- agentcompat-stress
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Require all blocking jobs to pass
shell: bash
run: |
test "${{ needs.tests.result }}" = success
test "${{ needs.linux-race-quality.result }}" = success
test "${{ needs.agentcompat-stress.result }}" = success
+4 -1
View File
@@ -24,4 +24,7 @@
/resource/template/theme-custom
/resource/static/custom
/cmd/dashboard/docs
/data/*
/app
/dashboard
.omo/
+1
View File
@@ -52,6 +52,7 @@ builds:
flags:
- -trimpath
- -buildvcs=false
- -mod=vendor
tags:
- go_json
goos:
+4 -3
View File
@@ -1,12 +1,13 @@
FROM alpine AS certs
RUN apk update && apk add ca-certificates
FROM alpine AS depend
RUN apk add --update --no-cache ca-certificates tzdata
FROM busybox:stable-musl
ARG TARGETOS
ARG TARGETARCH
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
COPY --from=depend /etc/ssl/certs /etc/ssl/certs
COPY --from=depend /usr/share/zoneinfo /usr/share/zoneinfo
COPY ./script/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
+5 -6
View File
@@ -1,5 +1,6 @@
# 哪吒面板 (Nezha Dashboard) - 个人编译指南
这份文档记录了在 Arch Linux 环境下,使用 VS Code Dev Containers 编译自定义主题版本的哪吒面板的完整流程。
# 哪吒面板 (Nezha Dashboard) - 域名增强定制版
本项目集成了域名管理(WHOIS/RDAP、Nazhumi 价格同步、到期提醒)、自定义通知系统与 Telegram Bot 交互、自定义 Branding、VPS 到期解析与可视化配置生成等核心功能。
## ⚠️ 核心前置条件 (不做会卡死)
网络环境:必须开启 TUN 模式 (透明代理)。
@@ -53,7 +54,6 @@ cp -r ./admin-frontend-domain/dist/* ./nezha_domains/cmd/dashboard/admin-dist/
确保 API 文档和 gRPC 代码是最新的。
```Bash
# 生成 Swagger 文档
swag init --pd -d . -g ./cmd/dashboard/main.go -o ./cmd/dashboard/docs --requiredByDefault
@@ -64,7 +64,6 @@ protoc --go-grpc_out="require_unimplemented_servers=false:." --go_out="." proto/
生成可执行文件 dashboard。
```Bash
# -s -w: 去除调试符号,减小体积
go build -ldflags="-s -w" -o dashboard cmd/dashboard/main.go
```
@@ -72,10 +71,10 @@ go build -ldflags="-s -w" -o dashboard cmd/dashboard/main.go
编译完成后,运行以下命令测试:
```Bash
# 运行面板
./dashboard
# 如果能看到 Logo 输出,或者提示 config.yaml 不存在,说明编译成功。
# 如果提示 user-dist 404 之类的,说明前端文件没复制对。
```
```
+1 -1
View File
@@ -6,4 +6,4 @@ Code in `master` branch.
## Reporting a Vulnerability
Thank you for your contribution to open source security, please email hi@nai.ba with details of the vulnerability.
Thank you for your contribution to open source security. Please report vulnerabilities privately at https://github.com/nezhahq/nezha/security or by email to [hi@nai.ba](mailto:hi@nai.ba).
View File
Whitespace-only changes.
@@ -0,0 +1,86 @@
//go:build agentcompat
package controller
import (
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
type agentcompatCapabilityIdentity struct {
purpose rpc.AgentCompatCapabilityPurpose
serverID uint64
resourceID uint64
}
type agentcompatCapabilityProof struct {
owner rpc.AgentCompatCapabilityOwner
identity agentcompatCapabilityIdentity
}
func currentAgentcompatCapabilityProof(context *gin.Context, identity agentcompatCapabilityIdentity) (agentcompatCapabilityProof, error) {
if err := validateAgentcompatCapabilityIdentity(identity.purpose, identity.serverID, identity.resourceID); err != nil {
return agentcompatCapabilityProof{}, err
}
token := APITokenFromContext(context)
authorized, present := context.Get(model.CtxKeyAuthorizedUser)
user, validUser := authorized.(*model.User)
if token == nil || token.ID == 0 || !present || !validUser || user == nil || user.ID == 0 {
return agentcompatCapabilityProof{}, errAgentcompatCapabilityUnavailable
}
if singleton.ServerShared == nil {
return agentcompatCapabilityProof{}, errAgentcompatCapabilityUnavailable
}
server, exists := singleton.ServerShared.Get(identity.serverID)
if !exists || server == nil || !server.HasPermission(context) || !patAllowsServer(context, identity.serverID) {
return agentcompatCapabilityProof{}, errAgentcompatCapabilityUnavailable
}
if identity.purpose == rpc.AgentCompatCapabilityNAT && !currentAgentcompatNATPermission(context, identity) {
return agentcompatCapabilityProof{}, errAgentcompatCapabilityUnavailable
}
return agentcompatCapabilityProof{
owner: rpc.AgentCompatCapabilityOwner{PATID: token.ID, UserID: user.ID, IsAdmin: user.Role.IsAdmin()},
identity: identity,
}, nil
}
func currentAgentcompatNATPermission(context *gin.Context, identity agentcompatCapabilityIdentity) bool {
if singleton.NATShared == nil {
return false
}
domain := singleton.NATShared.GetDomain(identity.resourceID)
if domain == "" {
return false
}
profile := singleton.NATShared.GetNATConfigByDomain(domain)
return profile != nil && profile.ID == identity.resourceID && profile.ServerID == identity.serverID && profile.HasPermission(context)
}
func (proof agentcompatCapabilityProof) registration() rpc.AgentCompatCapabilityRegistration {
return rpc.AgentCompatCapabilityRegistration{
Owner: proof.owner, Purpose: proof.identity.purpose, TargetServerID: proof.identity.serverID,
ResourceID: proof.identity.resourceID, ServerAccessAllowed: true,
}
}
func (proof agentcompatCapabilityProof) access(capability rpc.AgentCompatIOStreamCapability) rpc.AgentCompatCapabilityAccess {
return rpc.AgentCompatCapabilityAccess{
Capability: capability, Owner: proof.owner, Purpose: proof.identity.purpose,
TargetServerID: proof.identity.serverID, ResourceID: proof.identity.resourceID, ServerAccessAllowed: true,
}
}
func agentcompatCapabilityIdentityFromWire(purpose string, serverID, resourceID uint64) (agentcompatCapabilityIdentity, error) {
parsedPurpose, err := parseAgentcompatCapabilityPurpose(purpose)
if err != nil {
return agentcompatCapabilityIdentity{}, err
}
identity := agentcompatCapabilityIdentity{purpose: parsedPurpose, serverID: serverID, resourceID: resourceID}
if err := validateAgentcompatCapabilityIdentity(identity.purpose, identity.serverID, identity.resourceID); err != nil {
return agentcompatCapabilityIdentity{}, err
}
return identity, nil
}
@@ -0,0 +1,193 @@
//go:build agentcompat
package controller
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
)
func TestAgentcompatCapabilityBoundaryAcceptsExactly512Bytes(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
body := padAgentcompatCapabilityBody(t, `{"purpose":"terminal","server_id":7}`)
status, responseBody := postAgentcompatBoundary(t, server.URL+agentcompatCapabilityRegisterPath, token, body, true)
require.Equal(t, http.StatusOK, status)
var envelope model.CommonResponse[agentcompatCapabilityRegisterResponse]
require.NoError(t, json.Unmarshal([]byte(responseBody), &envelope))
require.True(t, envelope.Success)
require.NotEmpty(t, envelope.Data.Capability)
}
func TestAgentcompatCapabilityBoundaryRejects513BytesWithoutDisclosure(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
body := padAgentcompatCapabilityBody(t, `{"purpose":"terminal","server_id":7}`) + " "
for _, route := range []struct {
name string
path string
wantError bool
wantSuccess bool
}{
{name: "register", path: agentcompatCapabilityRegisterPath, wantError: true},
{name: "wait", path: agentcompatCapabilityWaitPath, wantError: true},
{name: "cancel", path: agentcompatCapabilityCancelPath, wantSuccess: true},
{name: "unregister", path: agentcompatCapabilityUnregisterPath, wantSuccess: true},
} {
t.Run(route.name, func(t *testing.T) {
status, responseBody := postAgentcompatBoundary(t, server.URL+route.path, token, body, true)
require.Equal(t, http.StatusOK, status)
require.NotContains(t, responseBody, "terminal")
require.NotContains(t, responseBody, "server_id")
if route.wantError {
var envelope model.CommonResponse[agentcompatCapabilityRegisterResponse]
require.NoError(t, json.Unmarshal([]byte(responseBody), &envelope))
require.False(t, envelope.Success)
require.Equal(t, errAgentcompatCapabilityInvalid.Error(), envelope.Error)
return
}
require.Equal(t, `{"success":true,"data":{}}`, responseBody)
})
}
}
func TestAgentcompatCapabilityBoundaryRejectsChunkedOversizeWithoutDisclosure(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
body := padAgentcompatCapabilityBody(t, `{"purpose":"terminal","server_id":7}`) + " "
status, responseBody := postAgentcompatBoundary(t, server.URL+agentcompatCapabilityRegisterPath, token, body, false)
require.Equal(t, http.StatusOK, status)
require.Contains(t, responseBody, errAgentcompatCapabilityInvalid.Error())
require.NotContains(t, responseBody, "terminal")
require.NotContains(t, responseBody, "server_id")
}
func TestAgentcompatCapabilityBoundaryRejectsDuplicateKeysInEitherOrder(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
keys := []string{"purpose", "server_id", "resource_id"}
for _, key := range keys {
for _, body := range []string{
`{"` + key + `":"terminal","` + key + `":"terminal","purpose":"terminal","server_id":7}`,
`{"` + key + `":0,"purpose":"terminal","server_id":7,"` + key + `":"terminal"}`,
} {
status, responseBody := postAgentcompatBoundary(t, server.URL+agentcompatCapabilityRegisterPath, token, body, true)
require.Equal(t, http.StatusOK, status)
require.Contains(t, responseBody, errAgentcompatCapabilityInvalid.Error())
require.NotContains(t, responseBody, "terminal")
}
}
}
func TestAgentcompatCapabilityBoundaryRejectsAccessGrammar(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
cases := []string{
`{"capability":"x","purpose":"terminal"}`,
`{"capability":"x","server_id":7}`,
`{"capability":"x","purpose":"terminal","server_id":7,"capability":"y"}`,
`{"capability":"x","purpose":"terminal","server_id":0}`,
`{"capability":"x","purpose":"terminal","server_id":7,"unknown":"private"}`,
`{"capability":"x","purpose":"terminal","server_id":7} {}`,
`{"capability":null,"purpose":"terminal","server_id":7}`,
`{"capability":7,"purpose":"terminal","server_id":7}`,
`{"capability":"x","purpose":7,"server_id":7}`,
`{"capability":"x","purpose":"terminal","server_id":"7"}`,
`{"capability":"x","purpose":"terminal","server_id":-1}`,
`{"capability":"x","purpose":"terminal","server_id":1.5}`,
`{"capability":"x","purpose":"terminal","server_id":1e2}`,
`null`, `[]`, `{`,
}
for _, body := range cases {
status, responseBody := postAgentcompatBoundary(t, server.URL+agentcompatCapabilityWaitPath, token, body, true)
require.Equal(t, http.StatusOK, status)
require.Contains(t, responseBody, errAgentcompatCapabilityInvalid.Error())
require.NotContains(t, responseBody, "private")
require.NotContains(t, responseBody, "x")
}
}
func TestAgentcompatCapabilityBoundaryRejectsMissingRegisterFields(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
for _, body := range []string{`{"server_id":7}`, `{"purpose":"terminal"}`, `{}`} {
status, responseBody := postAgentcompatBoundary(t, server.URL+agentcompatCapabilityRegisterPath, token, body, true)
require.Equal(t, http.StatusOK, status)
require.Contains(t, responseBody, errAgentcompatCapabilityInvalid.Error())
require.NotContains(t, responseBody, "terminal")
}
}
func padAgentcompatCapabilityBody(t *testing.T, body string) string {
t.Helper()
require.LessOrEqual(t, len(body), 512)
return body + strings.Repeat(" ", 512-len(body))
}
func postAgentcompatBoundary(t *testing.T, path, token, body string, contentLength bool) (int, string) {
t.Helper()
requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var reader io.Reader = bytes.NewBufferString(body)
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, path, reader)
require.NoError(t, err)
if !contentLength {
request.Body = io.NopCloser(strings.NewReader(body))
request.ContentLength = -1
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
return response.StatusCode, string(responseBody)
}
@@ -0,0 +1,199 @@
//go:build agentcompat
package controller
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/service/rpc"
)
const (
agentcompatCapabilityRequestMaxBytes = 512
agentcompatCapabilityRegisterPath = "/agentcompat/io-stream-capability/register"
agentcompatCapabilityWaitPath = "/agentcompat/io-stream-capability/wait"
agentcompatCapabilityCancelPath = "/agentcompat/io-stream-capability/cancel"
agentcompatCapabilityUnregisterPath = "/agentcompat/io-stream-capability/unregister"
)
const (
agentcompatCapabilityPurposeTerminal = "terminal"
agentcompatCapabilityPurposeFileManager = "file_manager"
agentcompatCapabilityPurposeNAT = "nat"
)
var (
errAgentcompatCapabilityInvalid = errors.New("agentcompat capability request is invalid")
errAgentcompatCapabilityUnavailable = errors.New("agentcompat capability is not available")
errAgentcompatCapabilityConflict = errors.New("agentcompat capability is active")
errAgentcompatCapabilityCleanup = errors.New("agentcompat capability cleanup failed")
)
type agentcompatCapabilityRegisterRequest struct {
Purpose string `json:"purpose"`
ServerID uint64 `json:"server_id"`
ResourceID uint64 `json:"resource_id,omitempty"`
}
type agentcompatCapabilityRegisterResponse struct {
Capability string `json:"capability"`
}
type agentcompatCapabilityAccessRequest struct {
Capability string `json:"capability"`
Purpose string `json:"purpose"`
ServerID uint64 `json:"server_id"`
ResourceID uint64 `json:"resource_id,omitempty"`
}
type agentcompatCapabilityWaitRequest = agentcompatCapabilityAccessRequest
type agentcompatCapabilityCancelRequest = agentcompatCapabilityAccessRequest
type agentcompatCapabilityUnregisterRequest = agentcompatCapabilityAccessRequest
type agentcompatCapabilityWaitResponse struct {
StreamID string `json:"stream_id"`
}
type agentcompatCapabilityEmptyResponse struct{}
func decodeAgentcompatCapabilityRequest[T any](context *gin.Context, destination *T) error {
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, agentcompatCapabilityRequestMaxBytes)
decoder := json.NewDecoder(context.Request.Body)
fields, err := decodeAgentcompatCapabilityObject(decoder)
if err != nil {
return errAgentcompatCapabilityInvalid
}
switch request := any(destination).(type) {
case *agentcompatCapabilityRegisterRequest:
request.Purpose, err = decodeAgentcompatCapabilityString(fields.purpose)
if err == nil {
request.ServerID, err = decodeAgentcompatCapabilityUint64(fields.serverID)
}
if err == nil && fields.resourceID != nil {
request.ResourceID, err = decodeAgentcompatCapabilityUint64(fields.resourceID)
}
if err != nil || fields.purpose == nil || fields.serverID == nil {
return errAgentcompatCapabilityInvalid
}
case *agentcompatCapabilityAccessRequest:
request.Capability, err = decodeAgentcompatCapabilityString(fields.capability)
if err == nil {
request.Purpose, err = decodeAgentcompatCapabilityString(fields.purpose)
}
if err == nil {
request.ServerID, err = decodeAgentcompatCapabilityUint64(fields.serverID)
}
if err == nil && fields.resourceID != nil {
request.ResourceID, err = decodeAgentcompatCapabilityUint64(fields.resourceID)
}
if err != nil || fields.capability == nil || fields.purpose == nil || fields.serverID == nil {
return errAgentcompatCapabilityInvalid
}
default:
return errAgentcompatCapabilityInvalid
}
return nil
}
type agentcompatCapabilityFields struct {
purpose json.RawMessage
serverID json.RawMessage
resourceID json.RawMessage
capability json.RawMessage
seen uint8
}
func decodeAgentcompatCapabilityObject(decoder *json.Decoder) (agentcompatCapabilityFields, error) {
var fields agentcompatCapabilityFields
token, err := decoder.Token()
if err != nil || token != json.Delim('{') {
return fields, errAgentcompatCapabilityInvalid
}
for decoder.More() {
keyToken, err := decoder.Token()
key, validKey := keyToken.(string)
if err != nil || !validKey {
return fields, errAgentcompatCapabilityInvalid
}
var bit uint8
var target *json.RawMessage
switch key {
case "purpose":
bit, target = 1, &fields.purpose
case "server_id":
bit, target = 2, &fields.serverID
case "resource_id":
bit, target = 4, &fields.resourceID
case "capability":
bit, target = 8, &fields.capability
default:
return fields, errAgentcompatCapabilityInvalid
}
if fields.seen&bit != 0 || decoder.Decode(target) != nil {
return fields, errAgentcompatCapabilityInvalid
}
fields.seen |= bit
}
if token, err = decoder.Token(); err != nil || token != json.Delim('}') {
return fields, errAgentcompatCapabilityInvalid
}
if _, err = decoder.Token(); !errors.Is(err, io.EOF) {
return fields, errAgentcompatCapabilityInvalid
}
return fields, nil
}
func decodeAgentcompatCapabilityString(raw json.RawMessage) (string, error) {
if strings.TrimSpace(string(raw)) == "null" {
return "", errAgentcompatCapabilityInvalid
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return "", errAgentcompatCapabilityInvalid
}
return value, nil
}
func decodeAgentcompatCapabilityUint64(raw json.RawMessage) (uint64, error) {
return strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
}
func parseAgentcompatCapabilityPurpose(value string) (rpc.AgentCompatCapabilityPurpose, error) {
switch value {
case agentcompatCapabilityPurposeTerminal:
return rpc.AgentCompatCapabilityTerminal, nil
case agentcompatCapabilityPurposeFileManager:
return rpc.AgentCompatCapabilityFileManager, nil
case agentcompatCapabilityPurposeNAT:
return rpc.AgentCompatCapabilityNAT, nil
default:
return 0, errAgentcompatCapabilityInvalid
}
}
func validateAgentcompatCapabilityIdentity(purpose rpc.AgentCompatCapabilityPurpose, serverID, resourceID uint64) error {
if serverID == 0 {
return errAgentcompatCapabilityInvalid
}
switch purpose {
case rpc.AgentCompatCapabilityTerminal, rpc.AgentCompatCapabilityFileManager:
if resourceID != 0 {
return errAgentcompatCapabilityInvalid
}
case rpc.AgentCompatCapabilityNAT:
if resourceID == 0 {
return errAgentcompatCapabilityInvalid
}
default:
return errAgentcompatCapabilityInvalid
}
return nil
}
@@ -0,0 +1,24 @@
//go:build !agentcompat
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestDefaultBuildDoesNotRegisterAgentcompatCapabilityRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
registerAgentcompatRoutes(router)
request := httptest.NewRequest(http.MethodPost, "/agentcompat/io-stream-capability/register", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusNotFound, response.Code)
}
@@ -0,0 +1,85 @@
//go:build agentcompat
package controller
import (
"context"
"errors"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
)
type agentcompatCapabilityCloseFailure struct {
closed atomic.Int32
}
func (*agentcompatCapabilityCloseFailure) Read([]byte) (int, error) { return 0, io.EOF }
func (*agentcompatCapabilityCloseFailure) Write(data []byte) (int, error) { return len(data), nil }
func (failure *agentcompatCapabilityCloseFailure) Close() error {
failure.closed.Add(1)
return errors.New("private-stream private-capability private-server")
}
func TestAgentcompatCapabilityBoundUnregisterAndCancelCleanupErrorsAreGeneric(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
handler := rpc.NewNezhaHandler()
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = handler
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
token, plaintext := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
capability := registerAgentcompatCapability(t, server.URL, plaintext, agentcompatCapabilityRegisterRequest{Purpose: "file_manager", ServerID: 7})
parsed, err := rpc.ParseAgentCompatIOStreamCapability(capability)
require.NoError(t, err)
access := rpc.AgentCompatCapabilityAccess{
Capability: parsed, Owner: rpc.AgentCompatCapabilityOwner{PATID: token.ID, UserID: userID},
Purpose: rpc.AgentCompatCapabilityFileManager, TargetServerID: 7, ServerAccessAllowed: true,
}
require.NoError(t, handler.CreateStreamWithPurpose("private-cleanup-stream", userID, 7, rpc.PurposeFileManager))
require.NoError(t, handler.BindAgentCompatIOStreamCapability(rpc.AgentCompatCapabilityBinding{AgentCompatCapabilityAccess: access, StreamID: "private-cleanup-stream"}))
unregisterStatus, unregisterBody := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityUnregisterPath, plaintext, agentcompatCapabilityUnregisterRequest{Capability: capability, Purpose: "file_manager", ServerID: 7})
require.Equal(t, http.StatusOK, unregisterStatus)
require.Contains(t, unregisterBody, errAgentcompatCapabilityConflict.Error())
require.NotContains(t, unregisterBody, capability)
require.NotContains(t, unregisterBody, "private-cleanup-stream")
failure := &agentcompatCapabilityCloseFailure{}
require.NoError(t, handler.UserConnected("private-cleanup-stream", failure))
cancelStatus, cancelBody := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityCancelPath, plaintext, agentcompatCapabilityCancelRequest{Capability: capability, Purpose: "file_manager", ServerID: 7})
require.Equal(t, http.StatusOK, cancelStatus)
require.Contains(t, cancelBody, errAgentcompatCapabilityCleanup.Error())
require.NotContains(t, cancelBody, capability)
require.NotContains(t, cancelBody, "private-cleanup-stream")
require.Equal(t, int32(1), failure.closed.Load())
}
func TestAgentcompatCapabilityWaitHonorsRequestCancellation(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, plaintext := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
capability := registerAgentcompatCapability(t, server.URL, plaintext, agentcompatCapabilityRegisterRequest{Purpose: "terminal", ServerID: 7})
requestContext, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
body := strings.NewReader(capabilityAccessJSON(capability, "terminal", 7, 0))
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, server.URL+agentcompatCapabilityWaitPath, body)
require.NoError(t, err)
request.Header.Set("Authorization", "Bearer "+plaintext)
request.Header.Set("Content-Type", "application/json")
_, err = server.Client().Do(request)
require.True(t, errors.Is(err, context.DeadlineExceeded))
}
@@ -0,0 +1,114 @@
//go:build agentcompat
package controller
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/service/rpc"
)
func registerAgentcompatCapabilityRoutes(router *gin.Engine, patAuth gin.HandlerFunc) {
router.POST(agentcompatCapabilityRegisterPath, patAuth, commonHandler(agentcompatCapabilityRegister))
router.POST(agentcompatCapabilityWaitPath, patAuth, commonHandler(agentcompatCapabilityWait))
router.POST(agentcompatCapabilityCancelPath, patAuth, commonHandler(agentcompatCapabilityCancel))
router.POST(agentcompatCapabilityUnregisterPath, patAuth, commonHandler(agentcompatCapabilityUnregister))
}
func agentcompatCapabilityRegister(context *gin.Context) (agentcompatCapabilityRegisterResponse, error) {
var request agentcompatCapabilityRegisterRequest
if err := decodeAgentcompatCapabilityRequest(context, &request); err != nil {
return agentcompatCapabilityRegisterResponse{}, err
}
identity, err := agentcompatCapabilityIdentityFromWire(request.Purpose, request.ServerID, request.ResourceID)
if err != nil {
return agentcompatCapabilityRegisterResponse{}, err
}
proof, err := currentAgentcompatCapabilityProof(context, identity)
if err != nil || rpc.NezhaHandlerSingleton == nil {
return agentcompatCapabilityRegisterResponse{}, errAgentcompatCapabilityUnavailable
}
capability, err := rpc.NezhaHandlerSingleton.RegisterAgentCompatIOStreamCapability(context.Request.Context(), proof.registration())
if err != nil {
if contextError := context.Request.Context().Err(); contextError != nil {
return agentcompatCapabilityRegisterResponse{}, contextError
}
return agentcompatCapabilityRegisterResponse{}, errAgentcompatCapabilityUnavailable
}
return agentcompatCapabilityRegisterResponse{Capability: capability.String()}, nil
}
func agentcompatCapabilityWait(context *gin.Context) (agentcompatCapabilityWaitResponse, error) {
var request agentcompatCapabilityWaitRequest
if err := decodeAgentcompatCapabilityRequest(context, &request); err != nil {
return agentcompatCapabilityWaitResponse{}, err
}
access, err := currentAgentcompatCapabilityAccess(context, request)
if errors.Is(err, errAgentcompatCapabilityInvalid) {
return agentcompatCapabilityWaitResponse{}, errAgentcompatCapabilityInvalid
}
if err != nil || rpc.NezhaHandlerSingleton == nil {
return agentcompatCapabilityWaitResponse{}, errAgentcompatCapabilityUnavailable
}
streamID, err := rpc.NezhaHandlerSingleton.WaitAgentCompatIOStreamCapability(context.Request.Context(), access)
if err != nil {
if contextError := context.Request.Context().Err(); contextError != nil {
return agentcompatCapabilityWaitResponse{}, contextError
}
return agentcompatCapabilityWaitResponse{}, errAgentcompatCapabilityUnavailable
}
return agentcompatCapabilityWaitResponse{StreamID: streamID}, nil
}
func agentcompatCapabilityCancel(context *gin.Context) (agentcompatCapabilityEmptyResponse, error) {
access, available := inertAgentcompatCapabilityAccess(context)
if !available || rpc.NezhaHandlerSingleton == nil {
return agentcompatCapabilityEmptyResponse{}, nil
}
if err := rpc.NezhaHandlerSingleton.CancelAgentCompatIOStreamCapability(access); err != nil {
return agentcompatCapabilityEmptyResponse{}, errAgentcompatCapabilityCleanup
}
return agentcompatCapabilityEmptyResponse{}, nil
}
func agentcompatCapabilityUnregister(context *gin.Context) (agentcompatCapabilityEmptyResponse, error) {
access, available := inertAgentcompatCapabilityAccess(context)
if !available || rpc.NezhaHandlerSingleton == nil {
return agentcompatCapabilityEmptyResponse{}, nil
}
err := rpc.NezhaHandlerSingleton.UnregisterAgentCompatIOStreamCapability(access)
if errors.Is(err, rpc.ErrAgentCompatCapabilityBound) {
return agentcompatCapabilityEmptyResponse{}, errAgentcompatCapabilityConflict
}
if err != nil {
return agentcompatCapabilityEmptyResponse{}, errAgentcompatCapabilityUnavailable
}
return agentcompatCapabilityEmptyResponse{}, nil
}
func currentAgentcompatCapabilityAccess(context *gin.Context, request agentcompatCapabilityAccessRequest) (rpc.AgentCompatCapabilityAccess, error) {
identity, err := agentcompatCapabilityIdentityFromWire(request.Purpose, request.ServerID, request.ResourceID)
if err != nil {
return rpc.AgentCompatCapabilityAccess{}, err
}
capability, err := rpc.ParseAgentCompatIOStreamCapability(request.Capability)
if err != nil {
return rpc.AgentCompatCapabilityAccess{}, errAgentcompatCapabilityUnavailable
}
proof, err := currentAgentcompatCapabilityProof(context, identity)
if err != nil {
return rpc.AgentCompatCapabilityAccess{}, errAgentcompatCapabilityUnavailable
}
return proof.access(capability), nil
}
func inertAgentcompatCapabilityAccess(context *gin.Context) (rpc.AgentCompatCapabilityAccess, bool) {
var request agentcompatCapabilityAccessRequest
if decodeAgentcompatCapabilityRequest(context, &request) != nil {
return rpc.AgentCompatCapabilityAccess{}, false
}
access, err := currentAgentcompatCapabilityAccess(context, request)
return access, err == nil
}
@@ -0,0 +1,109 @@
//go:build agentcompat
package controller
import (
"context"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
func TestAgentcompatNATCapabilityUsesExactCurrentProfilePermission(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
handler := rpc.NewNezhaHandler()
originalHandler := rpc.NezhaHandlerSingleton
originalNAT := singleton.NATShared
rpc.NezhaHandlerSingleton = handler
t.Cleanup(func() {
rpc.NezhaHandlerSingleton = originalHandler
singleton.NATShared = originalNAT
})
require.NoError(t, singleton.DB.AutoMigrate(&model.NAT{}))
secondServer := &model.Server{Common: model.Common{ID: 8}}
secondServer.SetUserID(userID)
singleton.ServerShared.InsertForTest(secondServer)
profile := &model.NAT{Common: model.Common{ID: 41, UserID: userID}, Name: "private-profile", ServerID: 7, Domain: "private-profile.example"}
foreignProfile := &model.NAT{Common: model.Common{ID: 42, UserID: 999}, Name: "foreign-profile", ServerID: 7, Domain: "foreign-profile.example"}
require.NoError(t, singleton.DB.Create(profile).Error)
require.NoError(t, singleton.DB.Create(foreignProfile).Error)
singleton.NATShared = singleton.NewNATClass()
token, plaintext := mkToken(t, userID, []string{model.ScopeNATRead}, []uint64{7, 8})
server := newAgentcompatCapabilityServer(t)
capability := registerAgentcompatCapability(t, server.URL, plaintext, agentcompatCapabilityRegisterRequest{Purpose: "nat", ServerID: 7, ResourceID: 41})
status, mismatchBody := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityRegisterPath, plaintext, agentcompatCapabilityRegisterRequest{Purpose: "nat", ServerID: 8, ResourceID: 41})
require.Equal(t, http.StatusOK, status)
require.Contains(t, mismatchBody, errAgentcompatCapabilityUnavailable.Error())
foreignStatus, foreignBody := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityRegisterPath, plaintext, agentcompatCapabilityRegisterRequest{Purpose: "nat", ServerID: 7, ResourceID: 42})
require.Equal(t, http.StatusOK, foreignStatus)
require.Contains(t, foreignBody, errAgentcompatCapabilityUnavailable.Error())
parsed, err := rpc.ParseAgentCompatIOStreamCapability(capability)
require.NoError(t, err)
access := rpc.AgentCompatCapabilityAccess{
Capability: parsed, Owner: rpc.AgentCompatCapabilityOwner{PATID: token.ID, UserID: userID},
Purpose: rpc.AgentCompatCapabilityNAT, TargetServerID: 7, ResourceID: 41, ServerAccessAllowed: true,
}
handle, err := handler.ConsumeAgentCompatNATCapability(access)
require.NoError(t, err)
lease, err := handler.CreateAgentCompatNATStream(handle, "private-nat-stream")
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, handler.CloseAgentCompatNATStreamLease(lease)) })
require.NoError(t, handler.PublishAgentCompatNATStream(handle, rpc.AgentCompatNATPublication{Purpose: rpc.AgentCompatCapabilityNAT, TargetServerID: 7, ResourceID: 41, StreamID: "private-nat-stream"}))
profile.ServerID = 8
singleton.NATShared.Update(profile)
_, unavailableErr := postAgentcompatCapability[agentcompatCapabilityWaitRequest, agentcompatCapabilityWaitResponse](context.Background(), server.URL+agentcompatCapabilityWaitPath, plaintext, agentcompatCapabilityWaitRequest{Capability: capability, Purpose: "nat", ServerID: 7, ResourceID: 41})
require.Error(t, unavailableErr)
require.NotContains(t, unavailableErr.Error(), capability)
require.NotContains(t, unavailableErr.Error(), "private-nat-stream")
profile.ServerID = 7
singleton.NATShared.Update(profile)
waitContext, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
waited, err := postAgentcompatCapability[agentcompatCapabilityWaitRequest, agentcompatCapabilityWaitResponse](waitContext, server.URL+agentcompatCapabilityWaitPath, plaintext, agentcompatCapabilityWaitRequest{Capability: capability, Purpose: "nat", ServerID: 7, ResourceID: 41})
require.NoError(t, err)
require.Equal(t, "private-nat-stream", waited.StreamID)
}
func TestAgentcompatCapabilityOwnerIncludesCurrentAdminRole(t *testing.T) {
cleanup, _ := setupMCPTest(t)
t.Cleanup(cleanup)
handler := rpc.NewNezhaHandler()
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = handler
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
admin := &model.User{Common: model.Common{ID: 300}, Username: "cap-admin", Role: model.RoleAdmin}
require.NoError(t, singleton.DB.Create(admin).Error)
target := &model.Server{Common: model.Common{ID: 8}}
target.SetUserID(999)
singleton.ServerShared.InsertForTest(target)
token, plaintext := mkDistinctCapabilityToken(t, admin.ID, "admin")
token.SetServerIDs([]uint64{8})
require.NoError(t, singleton.DB.Model(token).Update("servers_csv", token.ServersCSV).Error)
server := newAgentcompatCapabilityServer(t)
capability := registerAgentcompatCapability(t, server.URL, plaintext, agentcompatCapabilityRegisterRequest{Purpose: "terminal", ServerID: 8})
parsed, err := rpc.ParseAgentCompatIOStreamCapability(capability)
require.NoError(t, err)
require.NoError(t, handler.CreateStreamWithPurpose("admin-private-stream", admin.ID, 8, rpc.PurposeTerminal))
require.NoError(t, handler.BindAgentCompatIOStreamCapability(rpc.AgentCompatCapabilityBinding{
AgentCompatCapabilityAccess: rpc.AgentCompatCapabilityAccess{
Capability: parsed, Owner: rpc.AgentCompatCapabilityOwner{PATID: token.ID, UserID: admin.ID, IsAdmin: true},
Purpose: rpc.AgentCompatCapabilityTerminal, TargetServerID: 8, ServerAccessAllowed: true,
},
StreamID: "admin-private-stream",
}))
waitContext, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
waited, err := postAgentcompatCapability[agentcompatCapabilityWaitRequest, agentcompatCapabilityWaitResponse](waitContext, server.URL+agentcompatCapabilityWaitPath, plaintext, agentcompatCapabilityWaitRequest{Capability: capability, Purpose: "terminal", ServerID: 8})
require.NoError(t, err)
require.Equal(t, "admin-private-stream", waited.StreamID)
}
@@ -0,0 +1,175 @@
//go:build agentcompat
package controller
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
)
func TestAgentcompatCapabilityRoutesRequirePATAndDoNotAcceptOwnerFields(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
router := gin.New()
registerAgentcompatRoutes(router)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
requestBody := `{"purpose":"terminal","server_id":7,"pat_id":999,"user_id":999,"is_admin":true}`
requestContext, cancelRequests := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelRequests()
for _, authorization := range []string{"", "Bearer jwt-looking-value", "Bearer " + token} {
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, server.URL+agentcompatCapabilityRegisterPath, strings.NewReader(requestBody))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
if authorization != "" {
request.Header.Set("Authorization", authorization)
}
response, err := server.Client().Do(request)
require.NoError(t, err)
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
require.NoError(t, readErr)
if authorization == "Bearer "+token {
require.Equal(t, http.StatusOK, response.StatusCode)
var envelope model.CommonResponse[agentcompatCapabilityRegisterResponse]
require.NoError(t, json.Unmarshal(body, &envelope))
require.False(t, envelope.Success)
require.NotContains(t, string(body), "999")
continue
}
require.Equal(t, http.StatusUnauthorized, response.StatusCode)
}
}
func TestAgentcompatCapabilityRoutesRegisterWaitCancelUnregisterTypedLifecycle(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
tok, token := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
router := gin.New()
registerAgentcompatRoutes(router)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
capability := registerAgentcompatCapability(t, server.URL, token, agentcompatCapabilityRegisterRequest{Purpose: "terminal", ServerID: 7})
require.NoError(t, rpc.NezhaHandlerSingleton.CreateStreamWithPurpose("private-stream", userID, 7, rpc.PurposeTerminal))
parsed, err := rpc.ParseAgentCompatIOStreamCapability(capability)
require.NoError(t, err)
require.NoError(t, rpc.NezhaHandlerSingleton.BindAgentCompatIOStreamCapability(rpc.AgentCompatCapabilityBinding{
AgentCompatCapabilityAccess: rpc.AgentCompatCapabilityAccess{
Capability: parsed,
Owner: rpc.AgentCompatCapabilityOwner{PATID: tok.ID, UserID: userID},
Purpose: rpc.AgentCompatCapabilityTerminal, TargetServerID: 7,
ServerAccessAllowed: true,
},
StreamID: "private-stream",
}))
waitContext, cancelWait := context.WithTimeout(context.Background(), time.Second)
defer cancelWait()
result, err := postAgentcompatCapability[agentcompatCapabilityWaitRequest, agentcompatCapabilityWaitResponse](waitContext, server.URL+agentcompatCapabilityWaitPath, token, agentcompatCapabilityWaitRequest{Capability: capability, Purpose: "terminal", ServerID: 7})
require.NoError(t, err)
require.Equal(t, "private-stream", result.StreamID)
status, body := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityUnregisterPath, token, agentcompatCapabilityUnregisterRequest{Capability: capability, Purpose: "terminal", ServerID: 7})
require.Equal(t, http.StatusOK, status)
require.NotContains(t, body, capability)
status, body = postAgentcompatEmpty(t, server.URL+agentcompatCapabilityCancelPath, token, agentcompatCapabilityCancelRequest{Capability: capability, Purpose: "terminal", ServerID: 7})
require.Equal(t, http.StatusOK, status)
require.NotContains(t, body, capability)
}
func TestAgentcompatCapabilityRoutesWaitCancellationDoesNotLeakSecrets(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
router := gin.New()
registerAgentcompatRoutes(router)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
request := agentcompatCapabilityWaitRequest{Capability: strings.Repeat("a", 43), Purpose: "terminal", ServerID: 7}
status, body := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityWaitPath, token, request)
require.Equal(t, http.StatusOK, status)
require.NotContains(t, body, request.Capability)
require.NotContains(t, body, "private-stream")
}
func registerAgentcompatCapability(t *testing.T, baseURL, token string, request agentcompatCapabilityRegisterRequest) string {
t.Helper()
requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
response, err := postAgentcompatCapability[agentcompatCapabilityRegisterRequest, agentcompatCapabilityRegisterResponse](requestContext, baseURL+agentcompatCapabilityRegisterPath, token, request)
require.NoError(t, err)
require.NotEmpty(t, response.Capability)
return response.Capability
}
func postAgentcompatEmpty(t *testing.T, path, token string, body any) (int, string) {
t.Helper()
encoded, err := json.Marshal(body)
require.NoError(t, err)
requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, path, bytes.NewReader(encoded))
require.NoError(t, err)
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
defer response.Body.Close()
bodyBytes, err := io.ReadAll(response.Body)
require.NoError(t, err)
return response.StatusCode, string(bodyBytes)
}
func postAgentcompatCapability[Request, Response any](ctx context.Context, path, token string, body Request) (Response, error) {
var zero Response
encoded, err := json.Marshal(body)
if err != nil {
return zero, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(encoded))
if err != nil {
return zero, err
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
return zero, err
}
defer response.Body.Close()
var envelope model.CommonResponse[Response]
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
return zero, err
}
if !envelope.Success {
return zero, errors.New(envelope.Error)
}
return envelope.Data, nil
}
@@ -0,0 +1,238 @@
//go:build agentcompat
package controller
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
func TestAgentcompatCapabilityCancelAndUnregisterAreUniformAndNonMutating(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
handler := rpc.NewNezhaHandler()
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = handler
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
ownerToken, ownerPAT := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
_, foreignPAT := mkDistinctCapabilityToken(t, userID, "foreign")
server := newAgentcompatCapabilityServer(t)
capability := registerAgentcompatCapability(t, server.URL, ownerPAT, agentcompatCapabilityRegisterRequest{Purpose: agentcompatCapabilityPurposeTerminal, ServerID: 7})
bindAgentcompatTerminalCapability(t, handler, capability, ownerToken.ID, userID, 7, "private-stream")
start := handler.SnapshotIOStreamState()
unknown := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("u", 32)))
cases := []struct {
name string
path string
pat string
body string
}{
{name: "malformed cancel", path: agentcompatCapabilityCancelPath, pat: ownerPAT, body: `{"capability":`},
{name: "oversize cancel", path: agentcompatCapabilityCancelPath, pat: ownerPAT, body: strings.Repeat("x", 513)},
{name: "duplicate cancel", path: agentcompatCapabilityCancelPath, pat: ownerPAT, body: `{"capability":"x","capability":"y","purpose":"terminal","server_id":7}`},
{name: "unknown cancel", path: agentcompatCapabilityCancelPath, pat: ownerPAT, body: capabilityAccessJSON(unknown, "terminal", 7, 0)},
{name: "foreign cancel", path: agentcompatCapabilityCancelPath, pat: foreignPAT, body: capabilityAccessJSON(capability, "terminal", 7, 0)},
{name: "purpose cancel", path: agentcompatCapabilityCancelPath, pat: ownerPAT, body: capabilityAccessJSON(capability, "file_manager", 7, 0)},
{name: "server cancel", path: agentcompatCapabilityCancelPath, pat: ownerPAT, body: capabilityAccessJSON(capability, "terminal", 999999, 0)},
{name: "invalid unregister", path: agentcompatCapabilityUnregisterPath, pat: ownerPAT, body: capabilityAccessJSON("invalid", "terminal", 7, 0)},
{name: "oversize unregister", path: agentcompatCapabilityUnregisterPath, pat: ownerPAT, body: strings.Repeat("x", 513)},
{name: "duplicate unregister", path: agentcompatCapabilityUnregisterPath, pat: ownerPAT, body: `{"capability":"x","purpose":"terminal","server_id":7,"server_id":8}`},
{name: "foreign unregister", path: agentcompatCapabilityUnregisterPath, pat: foreignPAT, body: capabilityAccessJSON(capability, "terminal", 7, 0)},
{name: "resource unregister", path: agentcompatCapabilityUnregisterPath, pat: ownerPAT, body: capabilityAccessJSON(capability, "terminal", 7, 41)},
}
var uniformBody string
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
status, body := postAgentcompatRaw(t, server.URL+testCase.path, testCase.pat, testCase.body)
require.Equal(t, http.StatusOK, status)
if uniformBody == "" {
uniformBody = body
}
require.Equal(t, uniformBody, body)
require.Equal(t, start, handler.SnapshotIOStreamState())
require.NotContains(t, body, capability)
require.NotContains(t, body, "private-stream")
})
}
waitContext, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
waited, err := postAgentcompatCapability[agentcompatCapabilityWaitRequest, agentcompatCapabilityWaitResponse](waitContext, server.URL+agentcompatCapabilityWaitPath, ownerPAT, agentcompatCapabilityWaitRequest{Capability: capability, Purpose: "terminal", ServerID: 7})
require.NoError(t, err)
require.Equal(t, "private-stream", waited.StreamID)
}
func TestAgentcompatCapabilityPermissionRevocationIsRecheckedWithoutMutation(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
handler := rpc.NewNezhaHandler()
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = handler
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
token, plaintext := mkToken(t, userID, []string{model.ScopeServerRead}, []uint64{7})
server := newAgentcompatCapabilityServer(t)
capability := registerAgentcompatCapability(t, server.URL, plaintext, agentcompatCapabilityRegisterRequest{Purpose: "terminal", ServerID: 7})
bindAgentcompatTerminalCapability(t, handler, capability, token.ID, userID, 7, "revoked-private-stream")
start := handler.SnapshotIOStreamState()
token.SetServerIDs([]uint64{99})
require.NoError(t, singleton.DB.Model(token).Update("servers_csv", token.ServersCSV).Error)
cancelStatus, cancelBody := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityCancelPath, plaintext, agentcompatCapabilityCancelRequest{Capability: capability, Purpose: "terminal", ServerID: 7})
unregisterStatus, unregisterBody := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityUnregisterPath, plaintext, agentcompatCapabilityUnregisterRequest{Capability: capability, Purpose: "terminal", ServerID: 7})
require.Equal(t, http.StatusOK, cancelStatus)
require.Equal(t, http.StatusOK, unregisterStatus)
require.Equal(t, cancelBody, unregisterBody)
require.Equal(t, start, handler.SnapshotIOStreamState())
token.SetServerIDs(nil)
require.NoError(t, singleton.DB.Model(token).Update("servers_csv", "").Error)
waitContext, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
waited, err := postAgentcompatCapability[agentcompatCapabilityWaitRequest, agentcompatCapabilityWaitResponse](waitContext, server.URL+agentcompatCapabilityWaitPath, plaintext, agentcompatCapabilityWaitRequest{Capability: capability, Purpose: "terminal", ServerID: 7})
require.NoError(t, err)
require.Equal(t, "revoked-private-stream", waited.StreamID)
}
func TestAgentcompatCapabilityForeignWaitAndRevokedPATCannotObserveBinding(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
handler := rpc.NewNezhaHandler()
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = handler
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
ownerToken, ownerPAT := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
_, foreignPAT := mkDistinctCapabilityToken(t, userID, "foreign-wait")
server := newAgentcompatCapabilityServer(t)
capability := registerAgentcompatCapability(t, server.URL, ownerPAT, agentcompatCapabilityRegisterRequest{Purpose: "terminal", ServerID: 7})
bindAgentcompatTerminalCapability(t, handler, capability, ownerToken.ID, userID, 7, "foreign-private-stream")
start := handler.SnapshotIOStreamState()
foreignContext, cancelForeign := context.WithTimeout(context.Background(), time.Second)
defer cancelForeign()
_, foreignErr := postAgentcompatCapability[agentcompatCapabilityWaitRequest, agentcompatCapabilityWaitResponse](foreignContext, server.URL+agentcompatCapabilityWaitPath, foreignPAT, agentcompatCapabilityWaitRequest{Capability: capability, Purpose: "terminal", ServerID: 7})
require.Error(t, foreignErr)
require.NotContains(t, foreignErr.Error(), capability)
require.NotContains(t, foreignErr.Error(), "foreign-private-stream")
require.Equal(t, start, handler.SnapshotIOStreamState())
require.NoError(t, singleton.DB.Delete(&model.APIToken{}, ownerToken.ID).Error)
status, body := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityWaitPath, ownerPAT, agentcompatCapabilityAccessRequest{Capability: capability, Purpose: "terminal", ServerID: 7})
require.Equal(t, http.StatusUnauthorized, status)
require.NotContains(t, body, capability)
require.NotContains(t, body, "foreign-private-stream")
require.Equal(t, start, handler.SnapshotIOStreamState())
}
func TestAgentcompatCapabilityRegisterRequiresCurrentServerWhitelist(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, plaintext := mkToken(t, userID, []string{model.ScopeServerRead}, []uint64{99})
server := newAgentcompatCapabilityServer(t)
status, body := postAgentcompatEmpty(t, server.URL+agentcompatCapabilityRegisterPath, plaintext, agentcompatCapabilityRegisterRequest{Purpose: "file_manager", ServerID: 7})
require.Equal(t, http.StatusOK, status)
require.Contains(t, body, errAgentcompatCapabilityUnavailable.Error())
require.NotContains(t, body, "99")
require.NotContains(t, body, plaintext)
}
func TestAgentcompatCapabilityRegisterRejectsInvalidIdentityWithoutDisclosure(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, plaintext := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
privateServer := "777777777"
cases := []string{
`{`,
`{"purpose":"unknown","server_id":7}`,
`{"purpose":"terminal","server_id":0}`,
`{"purpose":"terminal","server_id":7,"resource_id":41}`,
`{"purpose":"nat","server_id":7}`,
`{"purpose":"terminal","server_id":` + privateServer + `}`,
}
for _, body := range cases {
status, responseBody := postAgentcompatRaw(t, server.URL+agentcompatCapabilityRegisterPath, plaintext, body)
require.Equal(t, http.StatusOK, status)
require.NotContains(t, responseBody, privateServer)
require.NotContains(t, responseBody, plaintext)
var envelope model.CommonResponse[agentcompatCapabilityRegisterResponse]
require.NoError(t, json.Unmarshal([]byte(responseBody), &envelope))
require.False(t, envelope.Success)
require.Empty(t, envelope.Data.Capability)
}
}
func newAgentcompatCapabilityServer(t *testing.T) *httptest.Server {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
registerAgentcompatRoutes(router)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
return server
}
func mkDistinctCapabilityToken(t *testing.T, userID uint64, suffix string) (*model.APIToken, string) {
t.Helper()
plaintext := "nzp_" + strings.Repeat("z", 32) + "_" + suffix
token := &model.APIToken{UserID: userID, Name: suffix, TokenHash: model.HashAPIToken(plaintext)}
token.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(token).Error)
return token, plaintext
}
func bindAgentcompatTerminalCapability(t *testing.T, handler *rpc.NezhaHandler, rawCapability string, tokenID, userID, serverID uint64, streamID string) {
t.Helper()
capability, err := rpc.ParseAgentCompatIOStreamCapability(rawCapability)
require.NoError(t, err)
access := rpc.AgentCompatCapabilityAccess{
Capability: capability, Owner: rpc.AgentCompatCapabilityOwner{PATID: tokenID, UserID: userID},
Purpose: rpc.AgentCompatCapabilityTerminal, TargetServerID: serverID, ServerAccessAllowed: true,
}
require.NoError(t, handler.CreateStreamWithPurpose(streamID, userID, serverID, rpc.PurposeTerminal))
require.NoError(t, handler.BindAgentCompatIOStreamCapability(rpc.AgentCompatCapabilityBinding{AgentCompatCapabilityAccess: access, StreamID: streamID}))
}
func capabilityAccessJSON(capability, purpose string, serverID, resourceID uint64) string {
body, _ := json.Marshal(agentcompatCapabilityAccessRequest{Capability: capability, Purpose: purpose, ServerID: serverID, ResourceID: resourceID})
return string(body)
}
func postAgentcompatRaw(t *testing.T, path, token, body string) (int, string) {
t.Helper()
requestContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, path, bytes.NewBufferString(body))
require.NoError(t, err)
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
return response.StatusCode, string(responseBody)
}
@@ -0,0 +1,147 @@
//go:build agentcompat
package controller
import (
"encoding/json"
"errors"
"fmt"
"io"
"time"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
)
const (
agentcompatOversizeWriteSentinel = "agentcompat:oversize-write-contract"
agentcompatOversizeWriteServerPath = "/tmp/agentcompat-oversize-contract.txt"
agentcompatFsWriteOperationOversize = "oversize"
)
type agentcompatFsWriteContractRequest struct {
ServerID uint64 `json:"server_id"`
Operation agentcompatFsWriteOperation `json:"operation"`
}
type agentcompatFsWriteOperation string
type agentcompatFsWriteContractResponse struct {
Result model.FsWriteResult `json:"result"`
AgentRPCResponse bool `json:"agent_rpc_response"`
}
func registerAgentcompatRoutes(router *gin.Engine) {
patAuth := requiredAgentcompatPAT(apiTokenAuthMiddleware())
router.POST("/agentcompat/fs-write-contract", patAuth, commonHandler(agentcompatFsWriteContract))
router.POST("/agentcompat/mcp-rate-limit-probe", patAuth, commonHandler(agentcompatMCPRateLimitProbeRoute))
router.GET("/agentcompat/io-stream-state", patAuth, commonHandler(agentcompatIOStreamSnapshot))
router.POST("/agentcompat/io-stream-state", patAuth, commonHandler(agentcompatIOStreamWait))
router.POST("/agentcompat/io-stream-quota-probe", patAuth, commonHandler(agentcompatIOStreamQuotaProbeRoute))
registerAgentcompatCapabilityRoutes(router, patAuth)
registerAgentcompatSQLiteHoldRoutes(router, patAuth)
}
type agentcompatIOStreamQuotaProbeResponse struct {
UserAccepted int `json:"user_accepted"`
UserRejected int `json:"user_rejected"`
ServerAccepted int `json:"server_accepted"`
ServerRejected int `json:"server_rejected"`
Clean bool `json:"clean"`
}
func agentcompatIOStreamQuotaProbeRoute(context *gin.Context) (agentcompatIOStreamQuotaProbeResponse, error) {
if rpc.NezhaHandlerSingleton == nil {
return agentcompatIOStreamQuotaProbeResponse{}, errors.New("IOStream handler is unavailable")
}
result := rpc.RunIOStreamQuotaProbe(context.Request.Context())
if result.Err != nil {
return agentcompatIOStreamQuotaProbeResponse{}, result.Err
}
return agentcompatIOStreamQuotaProbeResponse{UserAccepted: result.UserAccepted, UserRejected: result.UserRejected, ServerAccepted: result.ServerAccepted, ServerRejected: result.ServerRejected, Clean: result.TrackedStreams == 0}, nil
}
func requiredAgentcompatPAT(auth gin.HandlerFunc) gin.HandlerFunc {
return func(context *gin.Context) {
auth(context)
if context.IsAborted() {
return
}
if APITokenFromContext(context) == nil {
abortAPITokenUnauthorized(context, "api token required")
}
}
}
func agentcompatIOStreamSnapshot(*gin.Context) (rpc.IOStreamState, error) {
if rpc.NezhaHandlerSingleton == nil {
return rpc.IOStreamState{}, errors.New("IOStream handler is unavailable")
}
return rpc.NezhaHandlerSingleton.SnapshotIOStreamState(), nil
}
func agentcompatIOStreamWait(context *gin.Context) (rpc.IOStreamState, error) {
if rpc.NezhaHandlerSingleton == nil {
return rpc.IOStreamState{}, errors.New("IOStream handler is unavailable")
}
var expectation rpc.IOStreamStateExpectation
if err := context.ShouldBindJSON(&expectation); err != nil {
return rpc.IOStreamState{}, err
}
return rpc.NezhaHandlerSingleton.WaitForIOStreamState(context.Request.Context(), expectation)
}
func agentcompatFsWriteContract(context *gin.Context) (agentcompatFsWriteContractResponse, error) {
var request agentcompatFsWriteContractRequest
if err := decodeAgentcompatJSON(context, &request); err != nil {
return agentcompatFsWriteContractResponse{}, err
}
if request.ServerID == 0 || request.Operation == "" {
return agentcompatFsWriteContractResponse{}, errors.New("server_id and operation are required")
}
if request.Operation != agentcompatFsWriteOperation(agentcompatFsWriteOperationOversize) {
return agentcompatFsWriteContractResponse{}, errors.New("unknown filesystem write operation")
}
token := APITokenFromContext(context)
if token == nil || !token.HasScope(model.ScopeServerWrite) {
return agentcompatFsWriteContractResponse{}, errors.New("missing required scope: " + model.ScopeServerWrite)
}
server, err := requireServerAccess(context, request.ServerID)
if err != nil {
return agentcompatFsWriteContractResponse{}, err
}
content := "denied"
if request.Operation == agentcompatFsWriteOperation(agentcompatFsWriteOperationOversize) {
content = agentcompatOversizeWriteSentinel
}
// This tagged probe owns both path and payload; accepting either from callers would make it an arbitrary-write endpoint.
raw, err := rpc.CallAgent(context.Request.Context(), server.ID, model.TaskTypeFsWrite, model.FsWriteRequest{
Path: agentcompatOversizeWriteServerPath, Content: content, Encoding: "utf8", Mode: "0600",
}, 30*time.Second)
if err != nil {
return agentcompatFsWriteContractResponse{}, err
}
var result model.FsWriteResult
if err := json.Unmarshal(raw, &result); err != nil {
return agentcompatFsWriteContractResponse{}, err
}
return agentcompatFsWriteContractResponse{Result: result, AgentRPCResponse: true}, nil
}
func decodeAgentcompatJSON(context *gin.Context, value any) error {
decoder := json.NewDecoder(context.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return fmt.Errorf("trailing JSON values are not allowed")
}
return err
}
return nil
}
@@ -0,0 +1,7 @@
//go:build !agentcompat
package controller
import "github.com/gin-gonic/gin"
func registerAgentcompatRoutes(*gin.Engine) {}
@@ -0,0 +1,69 @@
//go:build agentcompat
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
func TestAgentcompatFsWriteContractRejectsCallerPayloadAndForeignServer(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
_, plaintext := mkToken(t, userID, []string{model.ScopeServerWrite}, []uint64{99})
server := newAgentcompatCapabilityServer(t)
status, body := postAgentcompatRaw(t, server.URL+"/agentcompat/fs-write-contract", plaintext, `{"server_id":7,"path":"/tmp/foreign","operation":"oversize","content":"attacker"}`)
require.Equal(t, http.StatusOK, status)
require.NotContains(t, body, "attacker")
require.Contains(t, body, "unknown field")
status, body = postAgentcompatRaw(t, server.URL+"/agentcompat/fs-write-contract", plaintext, `{"server_id":7,"operation":"oversize"}`)
require.Equal(t, http.StatusOK, status)
require.Contains(t, body, "permission denied")
}
func TestAgentcompatFsWriteContractRequiresWriteScopeBeforeRPC(t *testing.T) {
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
_, plaintext := mkToken(t, userID, []string{model.ScopeServerRead}, nil)
server := newAgentcompatCapabilityServer(t)
status, body := postAgentcompatRaw(t, server.URL+"/agentcompat/fs-write-contract", plaintext, `{"server_id":7,"operation":"oversize"}`)
require.Equal(t, http.StatusOK, status)
require.Contains(t, body, "missing required scope")
require.NotContains(t, body, `"agent_rpc_response":true`)
}
func TestAgentcompatProbeJSONRejectsUnknownFieldsAndTrailingValues(t *testing.T) {
tests := []string{
`{"server_id":7,"operation":"oversize","path":"caller"}`,
`{"server_id":7,"operation":"oversize","content":"caller"}`,
`{"server_id":7,"operation":"oversize"}{}`,
}
for _, body := range tests {
t.Run(body, func(t *testing.T) {
context := newAgentcompatJSONContext(t, body)
var request agentcompatFsWriteContractRequest
require.Error(t, decodeAgentcompatJSON(context, &request))
})
}
}
func newAgentcompatJSONContext(t *testing.T, body string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
context.Request.Header.Set("Content-Type", "application/json")
return context
}
@@ -0,0 +1,40 @@
//go:build agentcompat && linux
package controller
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/service/singleton"
)
func TestAgentcompatSQLiteHoldErrorUsesFixedRedactedMessages(t *testing.T) {
tests := []struct {
cause error
want string
}{
{agentcompatSQLiteHoldInvalidRequest{}, "agentcompat sqlite hold request is invalid"},
{singleton.ErrSQLiteHoldSessionActive, "agentcompat sqlite hold is active"},
{singleton.ErrSQLiteHoldStaleSession, "agentcompat sqlite hold receipt is stale"},
{singleton.ErrSQLiteHoldFinalizationNotStarted, "agentcompat sqlite hold is not ready"},
{singleton.ErrSQLiteHoldFinalizationStarted, "agentcompat sqlite hold is not ready"},
{singleton.ErrSQLiteHoldUnexpectedSelection, "agentcompat sqlite hold was aborted"},
{singleton.ErrSQLiteHoldAmbiguousCandidate, "agentcompat sqlite hold was aborted"},
{singleton.ErrSQLiteHoldAborted, "agentcompat sqlite hold was aborted"},
{context.Canceled, "agentcompat sqlite hold wait was canceled"},
{context.DeadlineExceeded, "agentcompat sqlite hold wait was canceled"},
{errors.New("path=/secret token=nzp_secret"), "agentcompat sqlite hold control is unavailable"},
}
for _, test := range tests {
// When
err := agentcompatSQLiteHoldError(test.cause)
// Then
require.EqualError(t, err, test.want)
}
}
@@ -0,0 +1,168 @@
//go:build agentcompat && linux
package controller
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/service/singleton"
)
const agentcompatSQLiteHoldPath = "/agentcompat/sqlite-hold/"
type agentcompatSQLiteHoldControl interface {
ArmNextSQLiteHold() (singleton.SQLiteHoldReceipt, error)
WaitSQLiteHoldSelected(context.Context, singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error)
WaitSQLiteHoldFinalizing(context.Context, singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error)
SnapshotSQLiteHold(singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error)
ReleaseSQLiteHold(singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error)
AbortSQLiteHold(singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error)
}
type agentcompatSQLiteHoldFacade struct{}
func (agentcompatSQLiteHoldFacade) ArmNextSQLiteHold() (singleton.SQLiteHoldReceipt, error) {
return singleton.ArmNextSQLiteHold()
}
func (agentcompatSQLiteHoldFacade) WaitSQLiteHoldSelected(ctx context.Context, receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
return singleton.WaitSQLiteHoldSelected(ctx, receipt)
}
func (agentcompatSQLiteHoldFacade) WaitSQLiteHoldFinalizing(ctx context.Context, receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
return singleton.WaitSQLiteHoldFinalizing(ctx, receipt)
}
func (agentcompatSQLiteHoldFacade) SnapshotSQLiteHold(receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
return singleton.SnapshotSQLiteHold(receipt)
}
func (agentcompatSQLiteHoldFacade) ReleaseSQLiteHold(receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
return singleton.ReleaseSQLiteHold(receipt)
}
func (agentcompatSQLiteHoldFacade) AbortSQLiteHold(receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
return singleton.AbortSQLiteHold(receipt)
}
var agentcompatSQLiteHoldController agentcompatSQLiteHoldControl = agentcompatSQLiteHoldFacade{}
type agentcompatSQLiteHoldRequest struct {
ID string `json:"id"`
State singleton.SQLiteHoldControlState `json:"state"`
}
func registerAgentcompatSQLiteHoldRoutes(router *gin.Engine, patAuth gin.HandlerFunc) {
readOnlyPAT := func(c *gin.Context) { suppressAPITokenAuthWrites(c) }
router.POST(agentcompatSQLiteHoldPath+"arm", readOnlyPAT, patAuth, commonHandler(agentcompatSQLiteHoldArm))
router.POST(agentcompatSQLiteHoldPath+"wait", readOnlyPAT, patAuth, commonHandler(agentcompatSQLiteHoldWait))
router.POST(agentcompatSQLiteHoldPath+"snapshot", readOnlyPAT, patAuth, commonHandler(agentcompatSQLiteHoldSnapshot))
router.POST(agentcompatSQLiteHoldPath+"release", readOnlyPAT, patAuth, commonHandler(agentcompatSQLiteHoldRelease))
router.POST(agentcompatSQLiteHoldPath+"abort", readOnlyPAT, patAuth, commonHandler(agentcompatSQLiteHoldAbort))
}
func agentcompatSQLiteHoldArm(c *gin.Context) (singleton.SQLiteHoldReceipt, error) {
var request struct{}
if err := decodeAgentcompatSQLiteHoldRequest(c, &request); err != nil {
return singleton.SQLiteHoldReceipt{}, agentcompatSQLiteHoldError(err)
}
receipt, err := agentcompatSQLiteHoldController.ArmNextSQLiteHold()
return receipt, agentcompatSQLiteHoldError(err)
}
func agentcompatSQLiteHoldWait(c *gin.Context) (singleton.SQLiteHoldReceipt, error) {
receipt, err := decodeAgentcompatSQLiteHoldReceipt(c, true)
if err != nil {
return singleton.SQLiteHoldReceipt{}, agentcompatSQLiteHoldError(err)
}
switch receipt.State {
case singleton.SQLiteHoldControlStateSelected:
receipt, err = agentcompatSQLiteHoldController.WaitSQLiteHoldSelected(c.Request.Context(), receipt)
case singleton.SQLiteHoldControlStateFinalizing:
receipt, err = agentcompatSQLiteHoldController.WaitSQLiteHoldFinalizing(c.Request.Context(), receipt)
default:
return singleton.SQLiteHoldReceipt{}, agentcompatSQLiteHoldInvalidRequest{}
}
return receipt, agentcompatSQLiteHoldError(err)
}
func agentcompatSQLiteHoldSnapshot(c *gin.Context) (singleton.SQLiteHoldReceipt, error) {
receipt, err := decodeAgentcompatSQLiteHoldReceipt(c, false)
if err != nil {
return singleton.SQLiteHoldReceipt{}, agentcompatSQLiteHoldError(err)
}
result, err := agentcompatSQLiteHoldController.SnapshotSQLiteHold(receipt)
return result, agentcompatSQLiteHoldError(err)
}
func agentcompatSQLiteHoldRelease(c *gin.Context) (singleton.SQLiteHoldReceipt, error) {
receipt, err := decodeAgentcompatSQLiteHoldReceipt(c, false)
if err != nil {
return singleton.SQLiteHoldReceipt{}, agentcompatSQLiteHoldError(err)
}
result, err := agentcompatSQLiteHoldController.ReleaseSQLiteHold(receipt)
return result, agentcompatSQLiteHoldError(err)
}
func agentcompatSQLiteHoldAbort(c *gin.Context) (singleton.SQLiteHoldReceipt, error) {
receipt, err := decodeAgentcompatSQLiteHoldReceipt(c, false)
if err != nil {
return singleton.SQLiteHoldReceipt{}, agentcompatSQLiteHoldError(err)
}
result, err := agentcompatSQLiteHoldController.AbortSQLiteHold(receipt)
return result, agentcompatSQLiteHoldError(err)
}
type agentcompatSQLiteHoldInvalidRequest struct{}
func (agentcompatSQLiteHoldInvalidRequest) Error() string {
return "agentcompat sqlite hold request is invalid"
}
func decodeAgentcompatSQLiteHoldRequest(c *gin.Context, value any) error {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 512)
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
return agentcompatSQLiteHoldInvalidRequest{}
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return agentcompatSQLiteHoldInvalidRequest{}
}
return nil
}
func decodeAgentcompatSQLiteHoldReceipt(c *gin.Context, requireState bool) (singleton.SQLiteHoldReceipt, error) {
var request agentcompatSQLiteHoldRequest
if err := decodeAgentcompatSQLiteHoldRequest(c, &request); err != nil {
return singleton.SQLiteHoldReceipt{}, err
}
decoded, err := base64.RawURLEncoding.DecodeString(request.ID)
if err != nil || len(request.ID) != 43 || len(decoded) != 32 {
return singleton.SQLiteHoldReceipt{}, agentcompatSQLiteHoldInvalidRequest{}
}
if !requireState && request.State != "" {
return singleton.SQLiteHoldReceipt{}, agentcompatSQLiteHoldInvalidRequest{}
}
return singleton.SQLiteHoldReceipt{ID: request.ID, State: request.State}, nil
}
func agentcompatSQLiteHoldError(err error) error {
if err == nil {
return nil
}
if errors.As(err, new(agentcompatSQLiteHoldInvalidRequest)) {
return agentcompatSQLiteHoldInvalidRequest{}
}
switch {
case errors.Is(err, singleton.ErrSQLiteHoldSessionActive):
return errors.New("agentcompat sqlite hold is active")
case errors.Is(err, singleton.ErrSQLiteHoldStaleSession):
return errors.New("agentcompat sqlite hold receipt is stale")
case errors.Is(err, singleton.ErrSQLiteHoldFinalizationNotStarted), errors.Is(err, singleton.ErrSQLiteHoldFinalizationStarted):
return errors.New("agentcompat sqlite hold is not ready")
case errors.Is(err, singleton.ErrSQLiteHoldUnexpectedSelection), errors.Is(err, singleton.ErrSQLiteHoldAmbiguousCandidate), errors.Is(err, singleton.ErrSQLiteHoldAborted):
return errors.New("agentcompat sqlite hold was aborted")
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return errors.New("agentcompat sqlite hold wait was canceled")
default:
return errors.New("agentcompat sqlite hold control is unavailable")
}
}
@@ -0,0 +1,201 @@
//go:build agentcompat && linux
package controller
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
type agentcompatSQLiteHoldControlProbe struct {
err error
armCalls int
lastCall string
waitContextError error
}
func (probe *agentcompatSQLiteHoldControlProbe) ArmNextSQLiteHold() (singleton.SQLiteHoldReceipt, error) {
probe.armCalls++
probe.lastCall = "arm"
return agentcompatSQLiteHoldTestReceipt(singleton.SQLiteHoldControlStateArmed), probe.err
}
func (probe *agentcompatSQLiteHoldControlProbe) WaitSQLiteHoldSelected(ctx context.Context, receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
probe.lastCall = "wait-selected"
probe.waitContextError = ctx.Err()
receipt.State = singleton.SQLiteHoldControlStateSelected
return receipt, probe.err
}
func (probe *agentcompatSQLiteHoldControlProbe) WaitSQLiteHoldFinalizing(ctx context.Context, receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
probe.lastCall = "wait-finalizing"
probe.waitContextError = ctx.Err()
receipt.State = singleton.SQLiteHoldControlStateFinalizing
return receipt, probe.err
}
func (probe *agentcompatSQLiteHoldControlProbe) SnapshotSQLiteHold(receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
probe.lastCall = "snapshot"
receipt.State = singleton.SQLiteHoldControlStateSelected
return receipt, probe.err
}
func (probe *agentcompatSQLiteHoldControlProbe) ReleaseSQLiteHold(receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
probe.lastCall = "release"
receipt.State = singleton.SQLiteHoldControlStateReleased
return receipt, probe.err
}
func (probe *agentcompatSQLiteHoldControlProbe) AbortSQLiteHold(receipt singleton.SQLiteHoldReceipt) (singleton.SQLiteHoldReceipt, error) {
probe.lastCall = "abort"
receipt.State = singleton.SQLiteHoldControlStateAborted
return receipt, probe.err
}
func TestAgentcompatSQLiteHoldRoutesRequirePATWithoutScopeOrAuthWrites(t *testing.T) {
// Given
router, token, storedToken, probe := setupAgentcompatSQLiteHoldRouteTest(t)
// When
status, response := requestAgentcompatSQLiteHold(t, router, context.Background(), "arm", token, `{}`)
// Then
require.Equal(t, http.StatusOK, status)
require.True(t, response.Success)
require.Equal(t, agentcompatSQLiteHoldTestReceipt(singleton.SQLiteHoldControlStateArmed), response.Data)
require.Equal(t, 1, probe.armCalls)
var refreshed model.APIToken
require.NoError(t, singleton.DB.First(&refreshed, storedToken.ID).Error)
require.Nil(t, refreshed.LastUsedAt)
require.Empty(t, refreshed.LastUsedIP)
for _, authorization := range []string{"", "jwt-looking-value", "nzp_invalid"} {
status, _ = requestAgentcompatSQLiteHold(t, router, context.Background(), "arm", authorization, `{}`)
require.Equal(t, http.StatusUnauthorized, status)
}
var wafRows int64
require.NoError(t, singleton.DB.Model(&model.WAF{}).Count(&wafRows).Error)
require.Zero(t, wafRows)
}
func TestAgentcompatSQLiteHoldRoutesRejectInvalidBodiesBeforeControl(t *testing.T) {
// Given
router, token, _, probe := setupAgentcompatSQLiteHoldRouteTest(t)
tests := []string{"", `{`, `{"unknown":true}`, `{} {}`, strings.Repeat(" ", 513) + `{}`}
for _, body := range tests {
// When
status, response := requestAgentcompatSQLiteHold(t, router, context.Background(), "arm", token, body)
// Then
require.Equal(t, http.StatusOK, status)
require.False(t, response.Success)
require.Equal(t, "agentcompat sqlite hold request is invalid", response.Error)
}
require.Zero(t, probe.armCalls)
}
func TestAgentcompatSQLiteHoldRoutesDispatchTypedLifecycleAndPropagateContext(t *testing.T) {
// Given
router, token, _, probe := setupAgentcompatSQLiteHoldRouteTest(t)
receiptID := agentcompatSQLiteHoldTestReceipt("").ID
tests := []struct {
path string
body string
wantCall string
wantState singleton.SQLiteHoldControlState
}{
{"wait", `{"id":"` + receiptID + `","state":"selected"}`, "wait-selected", singleton.SQLiteHoldControlStateSelected},
{"wait", `{"id":"` + receiptID + `","state":"finalizing"}`, "wait-finalizing", singleton.SQLiteHoldControlStateFinalizing},
{"snapshot", `{"id":"` + receiptID + `"}`, "snapshot", singleton.SQLiteHoldControlStateSelected},
{"release", `{"id":"` + receiptID + `"}`, "release", singleton.SQLiteHoldControlStateReleased},
{"abort", `{"id":"` + receiptID + `"}`, "abort", singleton.SQLiteHoldControlStateAborted},
}
for _, test := range tests {
// When
status, response := requestAgentcompatSQLiteHold(t, router, context.Background(), test.path, token, test.body)
// Then
require.Equal(t, http.StatusOK, status)
require.True(t, response.Success)
require.Equal(t, test.wantCall, probe.lastCall)
require.Equal(t, test.wantState, response.Data.State)
}
canceledContext, cancel := context.WithCancel(context.Background())
cancel()
status, response := requestAgentcompatSQLiteHold(t, router, canceledContext, "wait", token, `{"id":"`+receiptID+`","state":"selected"}`)
require.Equal(t, http.StatusOK, status)
require.True(t, response.Success)
require.ErrorIs(t, probe.waitContextError, context.Canceled)
status, response = requestAgentcompatSQLiteHold(t, router, context.Background(), "wait", token, `{"id":"`+receiptID+`","state":"armed"}`)
require.Equal(t, http.StatusOK, status)
require.False(t, response.Success)
require.Equal(t, "agentcompat sqlite hold request is invalid", response.Error)
}
func TestAgentcompatSQLiteHoldRoutesRedactControlErrors(t *testing.T) {
// Given
router, token, _, probe := setupAgentcompatSQLiteHoldRouteTest(t)
probe.err = errors.New("token=nzp_secret path=/root/private dashboard.sqlite-journal")
// When
status, response := requestAgentcompatSQLiteHold(t, router, context.Background(), "arm", token, `{}`)
// Then
require.Equal(t, http.StatusOK, status)
require.False(t, response.Success)
require.Equal(t, "agentcompat sqlite hold control is unavailable", response.Error)
encoded, err := json.Marshal(response)
require.NoError(t, err)
require.NotContains(t, string(encoded), "nzp_secret")
require.NotContains(t, string(encoded), "/root/private")
require.NotContains(t, string(encoded), "journal")
}
func setupAgentcompatSQLiteHoldRouteTest(t *testing.T) (*gin.Engine, string, *model.APIToken, *agentcompatSQLiteHoldControlProbe) {
t.Helper()
cleanup, userID := setupMCPTest(t)
t.Cleanup(cleanup)
storedToken, token := mkToken(t, userID, nil, nil)
probe := &agentcompatSQLiteHoldControlProbe{}
originalControl := agentcompatSQLiteHoldController
agentcompatSQLiteHoldController = probe
t.Cleanup(func() { agentcompatSQLiteHoldController = originalControl })
router := gin.New()
registerAgentcompatSQLiteHoldRoutes(router, requiredAgentcompatPAT(apiTokenAuthMiddleware()))
return router, token, storedToken, probe
}
func requestAgentcompatSQLiteHold(t *testing.T, router *gin.Engine, ctx context.Context, path, authorization, body string) (int, model.CommonResponse[singleton.SQLiteHoldReceipt]) {
t.Helper()
request := httptest.NewRequestWithContext(ctx, http.MethodPost, agentcompatSQLiteHoldPath+path, bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
if authorization != "" {
request.Header.Set("Authorization", "Bearer "+authorization)
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
var response model.CommonResponse[singleton.SQLiteHoldReceipt]
require.NoError(t, json.Unmarshal(responseRecorder.Body.Bytes(), &response))
return responseRecorder.Code, response
}
func agentcompatSQLiteHoldTestReceipt(state singleton.SQLiteHoldControlState) singleton.SQLiteHoldReceipt {
return singleton.SQLiteHoldReceipt{ID: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{17}, 32)), State: state}
}
@@ -0,0 +1,7 @@
//go:build agentcompat && !linux
package controller
import "github.com/gin-gonic/gin"
func registerAgentcompatSQLiteHoldRoutes(*gin.Engine, gin.HandlerFunc) {}
@@ -0,0 +1,27 @@
//go:build !agentcompat
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestAgentcompatSQLiteHoldRoutesAreAbsentWithoutBuildTag(t *testing.T) {
// Given
gin.SetMode(gin.TestMode)
router := gin.New()
registerAgentcompatRoutes(router)
request := httptest.NewRequest(http.MethodPost, "/agentcompat/sqlite-hold/arm", nil)
response := httptest.NewRecorder()
// When
router.ServeHTTP(response, request)
// Then
require.Equal(t, http.StatusNotFound, response.Code)
}
+40 -2
View File
@@ -1,7 +1,7 @@
package controller
import (
"maps"
"slices"
"strconv"
"time"
@@ -167,9 +167,23 @@ func batchDeleteAlertRule(c *gin.Context) (any, error) {
}
func validateRule(c *gin.Context, r *model.AlertRule) error {
if !r.HasPermission(c) {
return singleton.Localizer.ErrorT("permission denied")
}
if len(r.Rules) > 0 {
for _, rule := range r.Rules {
if !singleton.ServerShared.CheckPermission(c, maps.Keys(rule.Ignore)) {
if rule == nil {
return singleton.Localizer.ErrorT("rule is not set")
}
if !rule.IsSupportedType() {
return singleton.Localizer.ErrorT("unsupported rule type")
}
if !rule.HasSafeThresholds() {
return singleton.Localizer.ErrorT("invalid rule thresholds")
}
switch rule.Cover {
case model.RuleCoverAll, model.RuleCoverIgnoreAll:
default:
return singleton.Localizer.ErrorT("permission denied")
}
@@ -177,10 +191,19 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
if rule.Duration < 3 {
return singleton.Localizer.ErrorT("duration need to be at least 3")
}
if rule.Duration > model.MaxAlertRuleDuration {
return singleton.Localizer.ErrorT("duration is too large")
}
} else {
if !rule.HasSafeCycleUnit() {
return singleton.Localizer.ErrorT("invalid cycle unit")
}
if rule.CycleInterval < 1 {
return singleton.Localizer.ErrorT("cycle_interval need to be at least 1")
}
if rule.CycleInterval > model.MaxAlertRuleCycleInterval {
return singleton.Localizer.ErrorT("cycle_interval is too large")
}
if rule.CycleStart == nil {
return singleton.Localizer.ErrorT("cycle_start is not set")
}
@@ -192,5 +215,20 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
} else {
return singleton.Localizer.ErrorT("need to configure at least a single rule")
}
if !singleton.CronShared.CheckPermission(c, slices.Values(r.FailTriggerTasks)) {
return singleton.Localizer.ErrorT("permission denied")
}
if !singleton.CronShared.CheckPermission(c, slices.Values(r.RecoverTriggerTasks)) {
return singleton.Localizer.ErrorT("permission denied")
}
if err := enforcePATTriggerTaskScope(c, r.FailTriggerTasks, r.RecoverTriggerTasks); err != nil {
return err
}
if err := assertOwnsNotificationGroup(c, r.NotificationGroupID); err != nil {
return err
}
return nil
}
@@ -0,0 +1,146 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
func setupAlertRuleFanoutFixture(t *testing.T) {
t.Helper()
originalDB := singleton.DB
originalCache := singleton.Cache
originalLoc := singleton.Loc
originalLocalizer := singleton.Localizer
originalServer := singleton.ServerShared
originalCron := singleton.CronShared
originalUserInfo := singleton.UserInfoMap
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Server{}, &model.AlertRule{}, &model.Cron{}, &model.User{}))
singleton.DB = db
singleton.Loc = time.UTC
singleton.Cache = cache.New(time.Minute, time.Minute)
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
singleton.UserLock.Lock()
singleton.UserInfoMap = map[uint64]model.UserInfo{1: {Role: model.RoleAdmin}}
singleton.UserLock.Unlock()
require.NoError(t, db.Create(&model.Server{Common: model.Common{ID: 1, UserID: 1}, Name: "s1", UUID: "s1"}).Error)
require.NoError(t, db.Create(&model.Server{Common: model.Common{ID: 2, UserID: 1}, Name: "s2", UUID: "s2"}).Error)
singleton.ServerShared = singleton.NewServerClass()
singleton.CronShared = singleton.NewCronClass()
t.Cleanup(func() {
singleton.CronShared.Close()
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Cache = originalCache
singleton.Loc = originalLoc
singleton.Localizer = originalLocalizer
singleton.ServerShared = originalServer
singleton.CronShared = originalCron
singleton.UserLock.Lock()
singleton.UserInfoMap = originalUserInfo
singleton.UserLock.Unlock()
})
}
func newAlertRuleCtxWithPAT(t *testing.T, viewer *model.User, tok *model.APIToken, body any) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
raw, _ := json.Marshal(body)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/alert-rule", bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
if viewer != nil {
c.Set(model.CtxKeyAuthorizedUser, viewer)
}
if tok != nil {
c.Set(model.CtxKeyAPIToken, tok)
}
return c
}
// A server-limited PAT must not be able to create a RuleCoverAll rule with an
// empty Ignore (deny-list). Empty deny-list means "monitor every owner-visible
// server", which escapes the PAT's server_ids whitelist.
func TestCreateAlertRulePATCoverAllEmptyIgnoreRejected(t *testing.T) {
setupAlertRuleFanoutFixture(t)
tok := &model.APIToken{ID: 5, UserID: 1}
tok.SetServerIDs([]uint64{1})
form := map[string]any{
"name": "all-servers",
"enable": false,
"rules": []map[string]any{
{"type": "offline", "cover": model.RuleCoverAll, "duration": 10},
},
}
c := newAlertRuleCtxWithPAT(t, &model.User{Common: model.Common{ID: 1}, Role: model.RoleAdmin}, tok, form)
_, err := createAlertRule(c)
require.Error(t, err, "CoverAll + empty Ignore must be rejected for a PAT scoped to {1}")
var count int64
require.NoError(t, singleton.DB.Model(&model.AlertRule{}).Count(&count).Error)
assert.Equal(t, int64(0), count, "no alert rule should be persisted")
}
// The same PAT may create a RuleCoverAll rule when it explicitly denies every
// server outside its whitelist (here: server 2), since the fan-out is then
// confined to server 1. Exercised at validateRule to avoid the alert-sentinel
// side effects of a full createAlertRule.
func TestValidateRulePATCoverAllDenyingOutsideServersAllowed(t *testing.T) {
setupAlertRuleFanoutFixture(t)
tok := &model.APIToken{ID: 6, UserID: 1}
tok.SetServerIDs([]uint64{1})
c := newAlertRuleCtxWithPAT(t, &model.User{Common: model.Common{ID: 1}, Role: model.RoleAdmin}, tok, nil)
r := &model.AlertRule{
Common: model.Common{UserID: 1},
Name: "only-server-1",
Rules: []*model.Rule{{Type: "offline", Cover: model.RuleCoverAll, Duration: 10, Ignore: map[uint64]bool{2: true}}},
}
require.NoError(t, validateRule(c, r), "CoverAll denying every out-of-whitelist server must be allowed")
}
// Empty deny-list at validateRule level must also be rejected.
func TestValidateRulePATCoverAllEmptyIgnoreRejected(t *testing.T) {
setupAlertRuleFanoutFixture(t)
tok := &model.APIToken{ID: 7, UserID: 1}
tok.SetServerIDs([]uint64{1})
c := newAlertRuleCtxWithPAT(t, &model.User{Common: model.Common{ID: 1}, Role: model.RoleAdmin}, tok, nil)
r := &model.AlertRule{
Common: model.Common{UserID: 1},
Name: "all",
Rules: []*model.Rule{{Type: "offline", Cover: model.RuleCoverAll, Duration: 10}},
}
require.Error(t, validateRule(c, r), "CoverAll + empty Ignore must be rejected for PAT scoped to {1}")
}
@@ -0,0 +1,67 @@
package controller
import (
"math"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
func TestValidateRuleRejectsCrashPayloadsFromMember(t *testing.T) {
ctx := newMemberValidationContext(t)
cycleStart := time.Now().Add(-time.Hour)
tests := []struct {
name string
rule *model.Rule
}{
{
name: "unknown rule type",
rule: &model.Rule{Type: "attacker_controlled", Duration: 3, Cover: model.RuleCoverAll},
},
{
name: "duration overflows int",
rule: &model.Rule{Type: "offline", Duration: math.MaxUint64, Cover: model.RuleCoverAll},
},
{
name: "cycle interval overflows int",
rule: &model.Rule{
Type: "transfer_in_cycle",
CycleStart: &cycleStart,
CycleInterval: math.MaxUint64,
Cover: model.RuleCoverAll,
},
},
{
name: "nil rule",
rule: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
alert := &model.AlertRule{
Common: model.Common{UserID: 200},
Name: "security regression",
Rules: []*model.Rule{test.rule},
}
require.Error(t, validateRule(ctx, alert))
})
}
}
func TestValidateRuleAcceptsSafeDurationBoundary(t *testing.T) {
ctx := newMemberValidationContext(t)
alert := &model.AlertRule{
Common: model.Common{UserID: 200},
Name: "duration boundary",
Rules: []*model.Rule{{
Type: "offline",
Duration: model.MaxAlertRuleDuration,
Cover: model.RuleCoverAll,
}},
}
require.NoError(t, validateRule(ctx, alert))
}
+294
View File
@@ -0,0 +1,294 @@
package controller
import (
"errors"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/service/singleton"
)
const (
apiTokenSecretLength = 32 // 明文 token 随机部分长度(hex 编码前)
apiTokenCtxKey = "nz_api_token" // #nosec G101 -- gin context key name, not a credential
apiTokenLastUsedCtxKey = "nz_api_token_used_marker" // #nosec G101 -- gin context key name, not a credential
apiTokenReadOnlyCtxKey = "nz_api_token_read_only" // #nosec G101 -- gin context key name, not a credential
apiTokenAuthSchemePrefix = "Bearer "
)
// listAPITokens 列出当前用户的所有 PAT(脱敏,不含 token 明文)。
// @Summary List API tokens
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.APITokenView]
// @Router /api-tokens [get]
func listAPITokens(c *gin.Context) ([]model.APITokenView, error) {
uid := getUid(c)
var rows []model.APIToken
if err := singleton.DB.Where("user_id = ?", uid).Order("id DESC").Find(&rows).Error; err != nil {
return nil, newGormError("%v", err)
}
out := make([]model.APITokenView, 0, len(rows))
for i := range rows {
out = append(out, rows[i].ToView())
}
return out, nil
}
// createAPIToken 创建一个 PAT。明文 token 仅在响应中返回一次。
// @Summary Create API token
// @Tags auth required
// @Accept json
// @Param body body model.APITokenCreateRequest true "request"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.APITokenCreateResponse]
// @Router /api-tokens [post]
func createAPIToken(c *gin.Context) (*model.APITokenCreateResponse, error) {
var req model.APITokenCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
return nil, err
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return nil, errors.New("name required")
}
if len(req.Name) > 128 {
return nil, errors.New("name too long (max 128 chars)")
}
if req.ExpiresInDays < 0 {
return nil, errors.New("expires_in_days must be >= 0")
}
if req.ExpiresInDays > 3650 {
return nil, errors.New("expires_in_days too large (max 3650, i.e. 10 years)")
}
if len(req.Scopes) > 32 {
return nil, errors.New("too many scopes (max 32)")
}
if len(req.ServerIDs) > 1000 {
return nil, errors.New("too many server_ids (max 1000)")
}
allowed := append(append([]string{}, model.AllScopes...), model.AdminOnlyScopes...)
seen := make(map[string]struct{}, len(req.Scopes))
cleaned := make([]string, 0, len(req.Scopes))
for _, s := range req.Scopes {
s = strings.TrimSpace(s)
if s == "" {
continue
}
normalized, ok := model.NormalizeIncomingScope(s)
if !ok {
return nil, errors.New("unknown scope: " + s)
}
if !slices.Contains(allowed, normalized) {
return nil, errors.New("unknown scope: " + s)
}
if _, dup := seen[normalized]; dup {
continue
}
seen[normalized] = struct{}{}
cleaned = append(cleaned, normalized)
}
if len(cleaned) == 0 {
return nil, errors.New("at least one scope required")
}
if !callerIsAdmin(c) {
for _, s := range cleaned {
if slices.Contains(model.AdminOnlyScopes, s) {
return nil, errors.New("only admin can issue scope: " + s)
}
}
}
if len(req.ServerIDs) > 0 {
seenSrv := make(map[uint64]struct{}, len(req.ServerIDs))
deduped := make([]uint64, 0, len(req.ServerIDs))
for _, sid := range req.ServerIDs {
if sid == 0 {
return nil, errors.New("server_id 0 is invalid")
}
if _, dup := seenSrv[sid]; dup {
continue
}
seenSrv[sid] = struct{}{}
deduped = append(deduped, sid)
server, _ := singleton.ServerShared.Get(sid)
if server == nil {
return nil, errors.New("server not found")
}
if !callerIsAdmin(c) && !server.HasPermission(c) {
return nil, errors.New("permission denied on server")
}
}
req.ServerIDs = deduped
}
secret, err := utils.GenerateRandomString(apiTokenSecretLength)
if err != nil {
return nil, err
}
plaintext := model.APITokenPrefix + secret
tok := model.APIToken{
UserID: getUid(c),
Name: req.Name,
TokenHash: model.HashAPIToken(plaintext),
}
tok.SetScopes(cleaned)
if len(req.ServerIDs) > 0 {
tok.SetServerIDs(req.ServerIDs)
}
if req.ExpiresInDays > 0 {
exp := time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour)
tok.ExpiresAt = &exp
}
if err := singleton.DB.Create(&tok).Error; err != nil {
return nil, newGormError("%v", err)
}
return &model.APITokenCreateResponse{
ID: tok.ID,
Name: tok.Name,
Token: plaintext,
Scopes: tok.Scopes(),
ServerIDs: tok.ServerIDs(),
ExpiresAt: tok.ExpiresAt,
}, nil
}
// deleteAPIToken 吊销一个 PAT。
// @Summary Revoke API token
// @Tags auth required
// @Param id path uint true "token id"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /api-tokens/{id} [delete]
func deleteAPIToken(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
q := singleton.DB.Where("id = ?", id)
if !callerIsAdmin(c) {
q = q.Where("user_id = ?", getUid(c))
}
res := q.Delete(&model.APIToken{})
if res.Error != nil {
return nil, newGormError("%v", res.Error)
}
if res.RowsAffected == 0 {
return nil, errors.New("not found")
}
// Fan out the revocation to any active long-lived connection that
// carries this PAT — ws/server, ws/transfer, terminal, FM. Without
// this hook a deleted PAT keeps streaming until the underlying
// connection naturally drops.
patConnectionRegistryShared.revokeToken(id)
return nil, nil
}
// apiTokenAuthMiddleware 解析 `Authorization: Bearer nzp_xxx`,
// 命中后把 *model.User 挂到 ctx 上,使下游一切 Server.HasPermission/getUid 复用 JWT 路径。
//
// 不命中(无 Authorization 头或前缀不是 nzp_):放行下一个中间件(例如 JWT)。
// 命中但 token 无效:直接 401 并 abort,不再走到 JWT。
func apiTokenAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
readOnly := apiTokenAuthReadOnly(c)
raw := strings.TrimSpace(c.GetHeader("Authorization"))
if raw == "" {
return
}
if !strings.HasPrefix(raw, apiTokenAuthSchemePrefix) {
return
}
plaintext := strings.TrimSpace(strings.TrimPrefix(raw, apiTokenAuthSchemePrefix))
if !strings.HasPrefix(plaintext, model.APITokenPrefix) {
// 既然有 Bearer 但不是 PAT 前缀,交给后续 JWT 中间件处理
return
}
realIP := c.GetString(model.CtxKeyRealIPStr)
var tok model.APIToken
err := singleton.DB.Where("token_hash = ?", model.HashAPIToken(plaintext)).First(&tok).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
if !readOnly {
model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken)
}
abortAPITokenUnauthorized(c, "invalid api token")
return
}
abortAPITokenUnauthorized(c, "api token lookup failed")
return
}
now := time.Now()
if tok.IsExpired(now) {
if !readOnly {
model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken)
}
abortAPITokenUnauthorized(c, "api token expired")
return
}
var user model.User
if err := singleton.DB.First(&user, tok.UserID).Error; err != nil {
if !readOnly {
model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken)
}
abortAPITokenUnauthorized(c, "owner of api token not found")
return
}
if !readOnly {
model.UnblockIP(singleton.DB, realIP, model.BlockIDToken)
}
c.Set(model.CtxKeyAuthorizedUser, &user)
c.Set(apiTokenCtxKey, &tok)
c.Set(model.CtxKeyAPIToken, &tok)
// last_used 同步更新:开销极低(一行 UPDATE),异步路径在
// 多连接 sqlite 测试场景下会和测试 teardown 形成竞态,并把
// `last_used_*` 写丢到不可见的 :memory: 实例。生产路径上等价。
if !readOnly && apiTokenLastUsedOnce(c) {
c.Set(apiTokenLastUsedCtxKey, true)
_ = singleton.DB.Model(&model.APIToken{}).
Where("id = ?", tok.ID).
Updates(map[string]any{
"last_used_at": now,
"last_used_ip": c.GetString(model.CtxKeyRealIPStr),
}).Error
}
}
}
func abortAPITokenUnauthorized(c *gin.Context, reason string) {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorUnauthorized: " + reason,
})
}
// APITokenFromContext 取当前请求关联的 PAT,未命中返回 nil。
// MCP tool 中间件用它做 scope 校验(闸 2)。
func APITokenFromContext(c *gin.Context) *model.APIToken {
v, ok := c.Get(apiTokenCtxKey)
if !ok {
return nil
}
t, _ := v.(*model.APIToken)
return t
}
@@ -0,0 +1,78 @@
package controller
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
// createAPIToken 是「旧 mcp:* → 新 nezha:*」唯一的归一化入口:
// - mcp:fs:read / mcp:server:read 归一化为 nezha:server:read;
// - mcp:server:exec 归一化为 nezha:server:exec;
// - mcp:fs:write / mcp:fs:delete / mcp:* 不再可签发——它们历史上覆盖范围
// 比 nezha:server:write/delete 窄(只跑 MCP fs 工具),静默映射会扩权。
//
// 这样老调用方传旧 scope 还能创建只读 PAT,但拿不到 write/delete 提权。
func TestCreateAPIToken_RewritesLegacyReadScopeToNezhaRead(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "legacy-reader",
Scopes: []string{"mcp:fs:read"},
})
res, err := createAPIToken(c)
require.NoError(t, err, "legacy mcp:fs:read must be accepted at create time and rewritten")
require.Equal(t, []string{model.ScopeServerRead}, res.Scopes,
"create response must reflect the new unified scope name, not the legacy alias")
}
func TestCreateAPIToken_RewritesLegacyExecScopeToNezhaExec(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "legacy-exec",
Scopes: []string{"mcp:server:exec"},
})
res, err := createAPIToken(c)
require.NoError(t, err)
require.Equal(t, []string{model.ScopeServerExec}, res.Scopes)
}
func TestCreateAPIToken_RejectsLegacyMCPWriteScope(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "legacy-writer",
Scopes: []string{"mcp:fs:write"},
})
_, err := createAPIToken(c)
require.Error(t, err,
"mcp:fs:write must be rejected: silently mapping to nezha:server:write would expand the original "+
"MCP-only write capability to every REST server mutation route")
}
func TestCreateAPIToken_RejectsLegacyMCPDeleteScope(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "legacy-deleter",
Scopes: []string{"mcp:fs:delete"},
})
_, err := createAPIToken(c)
require.Error(t, err)
}
func TestCreateAPIToken_RejectsLegacyMCPWildcardScope(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "legacy-admin",
Scopes: []string{"mcp:*"},
})
_, err := createAPIToken(c)
require.Error(t, err,
"mcp:* must be rejected even for admin: the new unified namespace is nezha:* / nezha:admin:*")
}
@@ -0,0 +1,71 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
func setupOptionalAuthRouter(t *testing.T, plainToken string) *httptest.Server {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
jwtMw := func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no jwt"})
}
patMw := apiTokenAuthMiddleware()
authMw := jwtOrPATAuthMiddleware(patMw, jwtMw)
stub := func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
optionalAuth := r.Group("/api/v1", authMw)
optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeInventoryRead), stub)
optionalAuth.GET("/service", restScopeMiddleware(model.ScopeServiceRead), stub)
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), stub)
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
_ = plainToken
return ts
}
func TestOptionalAuth_PATWithoutScopeIsDenied(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
_, plain := mkToken(t, uid, []string{model.ScopeNotificationRead}, nil)
ts := setupOptionalAuthRouter(t, plain)
for _, path := range []string{
"/api/v1/server-group",
"/api/v1/service",
"/api/v1/server/7/metrics",
} {
resp := doReq(t, ts, "GET", path, plain)
resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode, "PAT lacking required scope must be denied for %s", path)
}
}
func TestOptionalAuth_PATWithMatchingScopeAllowed(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
_, plain := mkToken(t, uid, []string{model.ScopeInventoryRead, model.ScopeServerRead, model.ScopeServiceRead}, nil)
ts := setupOptionalAuthRouter(t, plain)
for _, path := range []string{
"/api/v1/server-group",
"/api/v1/service",
"/api/v1/server/7/metrics",
} {
resp := doReq(t, ts, "GET", path, plain)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "PAT with matching scope must pass for %s", path)
}
}
@@ -0,0 +1,19 @@
package controller
import "github.com/gin-gonic/gin"
func suppressAPITokenAuthWrites(c *gin.Context) { c.Set(apiTokenReadOnlyCtxKey, true) }
func apiTokenAuthReadOnly(c *gin.Context) bool {
value, ok := c.Get(apiTokenReadOnlyCtxKey)
return ok && value == true
}
func apiTokenLastUsedOnce(c *gin.Context) bool {
value, seen := c.Get(apiTokenLastUsedCtxKey)
if seen && value == true {
return false
}
c.Set(apiTokenLastUsedCtxKey, true)
return true
}
@@ -0,0 +1,148 @@
package controller
import (
"sync"
"time"
"github.com/nezhahq/nezha/model"
)
// revokeTombstoneTTL bounds how long a revoked token id is remembered to
// close the revoke->register race. The race window is a single request's
// auth-to-register gap (sub-second); minutes of slack is ample. Without a
// TTL the tombstone set grows unbounded over the process lifetime as PATs
// are created and deleted.
const revokeTombstoneTTL = 10 * time.Minute
// patConnectionRegistry tracks active long-lived connections (terminal,
// FM, ws/server, ws/transfer, etc.) per PAT id so that deleteAPIToken can
// cancel them immediately on revocation. Without this, a deleted PAT
// keeps streaming until the underlying connection naturally drops.
//
// The registry deliberately holds no goroutines — it only stores cancel
// hooks the connection setup already owns. Handlers register on entry
// and deregister on exit; revokeToken walks the per-token slice and
// invokes every hook under the lock.
type patConnectionRegistry struct {
mu sync.Mutex
byToken map[uint64]map[uint64]func()
// revoked is a tombstone set closing the revoke->register race: a
// connection can pass apiTokenAuthMiddleware (token cached in ctx) and
// only register its cancel hook AFTER deleteAPIToken already walked the
// registry. Without the tombstone that late registration would survive
// revocation. register consults revoked under the same lock and cancels
// immediately when the id is already gone.
revoked map[uint64]time.Time
nextID uint64
}
func newPATConnectionRegistry() *patConnectionRegistry {
return &patConnectionRegistry{
byToken: make(map[uint64]map[uint64]func()),
revoked: make(map[uint64]time.Time),
}
}
// pruneRevokedLocked drops tombstones older than revokeTombstoneTTL. Caller
// must hold r.mu. Bounds the tombstone set to recently-revoked ids.
func (r *patConnectionRegistry) pruneRevokedLocked(now time.Time) {
for id, at := range r.revoked {
if now.Sub(at) > revokeTombstoneTTL {
delete(r.revoked, id)
}
}
}
// register stores cancel under tokenID and returns a deregister hook the
// caller MUST invoke when the connection ends. Returning a closure
// (rather than exposing an id) prevents callers from forgetting to clean
// up and avoids leaking entries past connection lifetime.
//
// If tokenID was already revoked, register does NOT store the hook; it
// cancels immediately and returns a no-op deregister, so a connection that
// raced past revocation is torn down at once.
func (r *patConnectionRegistry) register(tokenID uint64, cancel func()) func() {
r.mu.Lock()
now := time.Now()
r.pruneRevokedLocked(now)
if at, dead := r.revoked[tokenID]; dead && now.Sub(at) <= revokeTombstoneTTL {
r.mu.Unlock()
cancel()
return func() {}
}
r.nextID++
id := r.nextID
conns, ok := r.byToken[tokenID]
if !ok {
conns = make(map[uint64]func())
r.byToken[tokenID] = conns
}
conns[id] = cancel
r.mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
r.mu.Lock()
defer r.mu.Unlock()
if m, ok := r.byToken[tokenID]; ok {
delete(m, id)
if len(m) == 0 {
delete(r.byToken, tokenID)
}
}
})
}
}
// revokeToken cancels every active connection registered under tokenID,
// clears the entry, and records a tombstone so any connection still racing
// toward register is cancelled on arrival. Safe to call on an unknown id.
func (r *patConnectionRegistry) revokeToken(tokenID uint64) {
r.mu.Lock()
conns := r.byToken[tokenID]
delete(r.byToken, tokenID)
now := time.Now()
r.pruneRevokedLocked(now)
r.revoked[tokenID] = now
r.mu.Unlock()
for _, cancel := range conns {
cancel()
}
}
// countForToken returns the number of active connections registered
// under tokenID. Intended for tests + future SIEM exposure; callers MUST
// NOT use it for policy decisions because the count can change the
// instant the lock is released.
func (r *patConnectionRegistry) countForToken(tokenID uint64) int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.byToken[tokenID])
}
var patConnectionRegistryShared = newPATConnectionRegistry()
// registerPATConnection wires the request-bound PAT (if any) into the
// process-wide revocation registry. Returns a deregister hook the
// handler MUST defer. For JWT-authenticated requests the hook is a
// no-op so call sites stay portable.
//
// Long-lived endpoints (terminal, FM, ws/server, ws/transfer) call
// this on entry and pass a cancel function that drops their websocket
// or relay loop. deleteAPIToken then revokes every active hook
// registered under the deleted token id.
func registerPATConnection(c interface {
Get(any) (any, bool)
}, cancel func()) func() {
v, ok := c.Get(apiTokenCtxKey)
if !ok {
return func() {}
}
tok, ok := v.(*model.APIToken)
if !ok || tok == nil {
return func() {}
}
return patConnectionRegistryShared.register(tok.ID, cancel)
}
@@ -0,0 +1,46 @@
package controller
import (
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
// 撤销发生在 register 之前时,迟到的连接必须被立即取消,而不是存活下来。
func TestRegisterAfterRevokeCancelsImmediately(t *testing.T) {
r := newPATConnectionRegistry()
r.revokeToken(42)
var cancelled atomic.Bool
dereg := r.register(42, func() { cancelled.Store(true) })
require.True(t, cancelled.Load(), "late registration on a revoked token must cancel at once")
require.Equal(t, 0, r.countForToken(42), "revoked token must not retain connections")
dereg() // must be a safe no-op
}
// 并发 revoke/register 下不得有连接逃过撤销。
func TestRevokeRegisterNoSurvivor(t *testing.T) {
for iter := 0; iter < 200; iter++ {
r := newPATConnectionRegistry()
var cancelled atomic.Bool
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
r.register(7, func() { cancelled.Store(true) })
}()
go func() {
defer wg.Done()
r.revokeToken(7)
}()
wg.Wait()
// 无论谁先跑:要么 register 先(被 revoke 取消),要么 revoke 先
// (register 在 tombstone 上立即取消)。两种顺序都不能留下活连接。
require.True(t, cancelled.Load(), "iter %d: connection survived revocation", iter)
require.Equal(t, 0, r.countForToken(7), "iter %d: registry must be empty after revoke", iter)
}
}
@@ -0,0 +1,99 @@
package controller
import (
"context"
"testing"
"time"
)
// M7 regression: long-lived PAT-authenticated handlers (ws/server,
// ws/transfer, terminal, FM) must register a cancel hook so that
// deleteAPIToken can close active connections immediately. Without this,
// a revoked PAT keeps streaming until the connection naturally drops.
func TestPATConnectionRegistry_CancelsOnRevoke(t *testing.T) {
registry := newPATConnectionRegistry()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
deregister := registry.register(42, cancel)
defer deregister()
registry.revokeToken(42)
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("revokeToken must cancel the registered context within 1s")
}
}
func TestPATConnectionRegistry_DoesNotCancelOtherTokens(t *testing.T) {
registry := newPATConnectionRegistry()
ctxA, cancelA := context.WithCancel(context.Background())
ctxB, cancelB := context.WithCancel(context.Background())
defer cancelA()
defer cancelB()
deregisterA := registry.register(1, cancelA)
deregisterB := registry.register(2, cancelB)
defer deregisterA()
defer deregisterB()
registry.revokeToken(1)
select {
case <-ctxA.Done():
case <-time.After(time.Second):
t.Fatal("token 1's connection must be cancelled")
}
select {
case <-ctxB.Done():
t.Fatal("token 2's connection must NOT be cancelled (separate token)")
case <-time.After(50 * time.Millisecond):
}
}
func TestPATConnectionRegistry_DeregisterClearsEntry(t *testing.T) {
registry := newPATConnectionRegistry()
_, cancel := context.WithCancel(context.Background())
deregister := registry.register(1, cancel)
deregister()
if got := registry.countForToken(1); got != 0 {
t.Fatalf("after deregister, count for token 1 must be 0, got %d", got)
}
}
func TestPATConnectionRegistry_MultipleConnsPerToken(t *testing.T) {
registry := newPATConnectionRegistry()
ctx1, c1 := context.WithCancel(context.Background())
ctx2, c2 := context.WithCancel(context.Background())
defer c1()
defer c2()
d1 := registry.register(7, c1)
d2 := registry.register(7, c2)
defer d1()
defer d2()
if got := registry.countForToken(7); got != 2 {
t.Fatalf("expected 2 connections for token 7, got %d", got)
}
registry.revokeToken(7)
for _, ctx := range []context.Context{ctx1, ctx2} {
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("all connections for the revoked token must be cancelled")
}
}
}
func TestPATConnectionRegistry_RevokeUnknownTokenIsNoOp(t *testing.T) {
registry := newPATConnectionRegistry()
registry.revokeToken(999) // must not panic
}
+135
View File
@@ -0,0 +1,135 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
)
// jwtOrPATAuthMiddleware 把 PAT 与 JWT 两条鉴权链组合到 /api/v1/* 入口。
//
// 处理顺序:
// 1. apiTokenAuthMiddleware:识别 `Authorization: Bearer nzp_*`。命中(合法 PAT)
// 把 user 挂到 ctx;非法 PAT 直接 abort 401。
// 2. 如果 PAT 已挂 user → 跳过 JWT。
// 3. 否则 → JWT 中间件接管,按现有 cookie / Bearer / query token 逻辑鉴权。
//
// 存量 JWT 客户端零感知;新 PAT 客户端可直接调 REST,但每个端点的 scope
// 仍由 restScopeMiddleware 控制。
func jwtOrPATAuthMiddleware(patMw, jwtMw gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
patMw(c)
if c.IsAborted() {
return
}
if APITokenFromContext(c) != nil {
return
}
jwtMw(c)
if c.IsAborted() {
return
}
}
}
// patOrFallbackAuthMiddleware 是 optional 路由(ForceAuth=false 时也能匿名访问)
// 的鉴权链:
// 1. apiTokenAuthMiddleware:识别 PAT,命中后挂 user;非法 PAT 401 abort。
// 2. 已挂 PAT → 跳过 JWT,restScopeMiddleware 会按 scope 收口。
// 3. 未带 PAT → 走 fallbackJwtMw,存在 JWT 则挂 user,没有就匿名继续。
//
// 这是修复 ForceAuth=false 时 optional 路由完全不解析 PAT 的关键:
// 之前直接用 fallbackAuthMw 会让 PAT 请求被当作 guest,scope 形同虚设。
func patOrFallbackAuthMiddleware(patMw, fallbackJwtMw gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
patMw(c)
if c.IsAborted() {
return
}
if APITokenFromContext(c) != nil {
return
}
fallbackJwtMw(c)
}
}
// restScopeMiddleware 在 /api/v1/* 路由上 enforce PAT scope。
//
// 行为:
// - JWT 持有者(任何来源:cookie / Authorization Bearer 非 nzp_)→ 直接放行,
// 沿用 JWT 模型的完整权限。
// - PAT 持有者 → 必须命中给定 scope。命中后下游 handler 仍受 user 级权限
// 检查(adminHandler / Server.HasPermission),scope 只能收窄不能放大。
// - PAT 持有者遇到 scope=="" → 直接 403。空字符串作为 fail-closed 默认值,
// 防止接入新路由时忘填 scope 把 PAT 静默放行。
//
// 因此"自我管理"端点(/profile、/api-tokens、/refresh-token 等)必须显式挂
// restPATForbiddenMiddleware 来拒绝 PAT,而不是依赖空 scope 兜底。
func restScopeMiddleware(scope string) gin.HandlerFunc {
return func(c *gin.Context) {
tok := APITokenFromContext(c)
if tok == nil {
c.Next()
return
}
if scope == "" || !tok.HasScope(scope) {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: api token lacks scope " + scope,
})
return
}
c.Next()
}
}
// restScopeAllOf is the multi-scope variant of restScopeMiddleware. It
// gates on EVERY listed scope, used by routes whose semantics span more
// than one capability — file-manager sessions read, write AND delete
// files, so a PAT that only carries nezha:server:write must NOT be allowed
// to open one. JWT callers pass through unchanged.
func restScopeAllOf(scopes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
tok := APITokenFromContext(c)
if tok == nil {
c.Next()
return
}
for _, scope := range scopes {
if scope == "" || !tok.HasScope(scope) {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: api token lacks scope " + scope,
})
return
}
}
c.Next()
}
}
// serverConfigSensitiveScope 收紧 GET /server/config/:id 的 PAT scope 到
// ScopeServerWrite:返回体里包含 client_secret 等下发到 agent 的凭据,单纯
// nezha:server:read 不应足以读取。命名刻意带 Sensitive 而不是 Read,避免下
// 个维护者把它当成普通 read scope 还原成 ScopeServerRead 重新打开提权链。
func serverConfigSensitiveScope() string { return model.ScopeServerWrite }
// restPATForbiddenMiddleware 在「自我管理」类端点上显式拒绝 PAT。
//
// 这些端点(profile / api-tokens / oauth2 绑定 / refresh-token)一旦允许 PAT
// 自调,就可形成提权链(PAT → 创建更高权限 PAT → ...)。
// 显式 403 比静默放行更安全。
func restPATForbiddenMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if APITokenFromContext(c) != nil {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: this endpoint is not accessible by api token",
})
return
}
c.Next()
}
}
@@ -0,0 +1,58 @@
package controller
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
)
// restScopeMiddleware 的"空 scope"实际行为:PAT 调用方一律 403。
// 这条测试把注释与实现的契约对齐:
// 1. 实际行为:PAT + scope="" → 403。
// 2. 文档约束:源码注释必须明确说出"空 scope 对 PAT 仍被拒绝",
// 不能再保留"空字符串 = 放行"这种与实现相反的旧措辞。
func TestRestScopeMiddleware_EmptyScopeRejectsPAT(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/x", func(c *gin.Context) {
c.Set(apiTokenCtxKey, &model.APIToken{ID: 1})
c.Set(model.CtxKeyAPIToken, &model.APIToken{ID: 1})
c.Next()
}, restScopeMiddleware(""), func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/x", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 when PAT hits restScopeMiddleware(\"\"); got %d body=%q", w.Code, w.Body.String())
}
}
func TestRestScopeMiddleware_DocReflectsEmptyScopeRejection(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
b, err := os.ReadFile(filepath.Join(wd, "api_token_scope.go"))
if err != nil {
t.Fatalf("read: %v", err)
}
src := string(b)
idx := strings.Index(src, "func restScopeMiddleware(")
if idx < 0 {
t.Fatalf("restScopeMiddleware not found")
}
doc := src[:idx]
if strings.Contains(doc, "空字符串)= 放行") || strings.Contains(doc, "空字符串) = 放行") {
t.Fatalf("doc still claims empty scope means 放行; this contradicts the implementation which 403s PAT callers")
}
}
@@ -0,0 +1,223 @@
package controller
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// 在 /api/v1 风格的小 router 上重现 PAT + scope mw,验证 enforcement。
func setupRESTScopeServer(t *testing.T) (*httptest.Server, string, func()) {
t.Helper()
cleanupBase, uid := setupMCPTest(t)
_, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
gin.SetMode(gin.TestMode)
r := gin.New()
pat := apiTokenAuthMiddleware()
r.GET("/api/v1/server",
pat,
restScopeMiddleware(model.ScopeInventoryRead),
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
)
r.POST("/api/v1/server/config",
pat,
restScopeMiddleware(model.ScopeServerWrite),
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
)
r.GET("/api/v1/profile",
pat,
restPATForbiddenMiddleware(),
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
)
ts := httptest.NewServer(r)
return ts, plain, func() {
ts.Close()
cleanupBase()
}
}
func httpGetWithToken(t *testing.T, ts *httptest.Server, path, token string) (int, map[string]any) {
t.Helper()
req, _ := http.NewRequest("GET", ts.URL+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var out map[string]any
_ = json.Unmarshal(body, &out)
return resp.StatusCode, out
}
func httpPostWithToken(t *testing.T, ts *httptest.Server, path, token string) (int, map[string]any) {
t.Helper()
req, _ := http.NewRequest("POST", ts.URL+path, strings.NewReader("{}"))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var out map[string]any
_ = json.Unmarshal(body, &out)
return resp.StatusCode, out
}
func TestRESTScope_PATWithReadCanGET(t *testing.T) {
ts, tok, cleanup := setupRESTScopeServer(t)
defer cleanup()
code, body := httpGetWithToken(t, ts, "/api/v1/server", tok)
require.Equal(t, 200, code)
require.True(t, body["ok"].(bool))
}
func TestRESTScope_PATWithReadCannotWrite(t *testing.T) {
ts, tok, cleanup := setupRESTScopeServer(t)
defer cleanup()
code, body := httpPostWithToken(t, ts, "/api/v1/server/config", tok)
require.Equal(t, 403, code)
require.Contains(t, body["error"], "nezha:server:write")
}
func TestRESTScope_NoTokenIsTransparentToScopeMW(t *testing.T) {
ts, _, cleanup := setupRESTScopeServer(t)
defer cleanup()
code, _ := httpGetWithToken(t, ts, "/api/v1/server", "")
require.Equal(t, 200, code, "scope mw is PAT-only enforcement; JWT flow is gated by jwtOrPATAuthMiddleware before this layer. In this minimal router there is no JWT mw, so no token = handler runs (security is enforced upstream)")
}
func TestRESTScope_PATForbiddenOnSelfManagement(t *testing.T) {
ts, tok, cleanup := setupRESTScopeServer(t)
defer cleanup()
code, body := httpGetWithToken(t, ts, "/api/v1/profile", tok)
require.Equal(t, 403, code)
require.Contains(t, body["error"], "not accessible by api token")
}
func TestRESTScope_JWTUserSkipsScope(t *testing.T) {
cleanupBase, uid := setupMCPTest(t)
defer cleanupBase()
gin.SetMode(gin.TestMode)
r := gin.New()
pat := apiTokenAuthMiddleware()
r.GET("/api/v1/server",
pat,
func(c *gin.Context) {
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: uid}, Role: model.RoleMember})
c.Next()
},
restScopeMiddleware(model.ScopeInventoryRead),
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
)
ts := httptest.NewServer(r)
defer ts.Close()
code, body := httpGetWithToken(t, ts, "/api/v1/server", "")
require.Equal(t, 200, code, "JWT-attached request (no PAT in ctx) must bypass scope check")
require.True(t, body["ok"].(bool))
}
func TestRESTScope_NezhaAllUnlocksEverything(t *testing.T) {
cleanupBase, uid := setupMCPTest(t)
defer cleanupBase()
_, plain := mkToken(t, uid, []string{model.ScopeNezhaAll}, nil)
gin.SetMode(gin.TestMode)
r := gin.New()
pat := apiTokenAuthMiddleware()
r.POST("/api/v1/server/config",
pat,
restScopeMiddleware(model.ScopeServerWrite),
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
)
r.POST("/api/v1/batch-delete/server",
pat,
restScopeMiddleware(model.ScopeInventoryDelete),
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
)
ts := httptest.NewServer(r)
defer ts.Close()
for _, path := range []string{"/api/v1/server/config", "/api/v1/batch-delete/server"} {
code, _ := httpPostWithToken(t, ts, path, plain)
require.Equalf(t, 200, code, "nezha:* must unlock %s", path)
}
}
// --- WAF brute force ---
func TestRESTScope_BadPATIncrementsWAFCounter(t *testing.T) {
cleanupBase, _ := setupMCPTest(t)
defer cleanupBase()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(model.CtxKeyRealIPStr, "203.0.113.7")
c.Next()
})
r.GET("/api/v1/server",
apiTokenAuthMiddleware(),
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
)
ts := httptest.NewServer(r)
defer ts.Close()
for i := 0; i < 3; i++ {
code, _ := httpGetWithToken(t, ts, "/api/v1/server", "nzp_invalid_token_xxx")
require.Equal(t, 401, code)
}
var w model.WAF
require.NoError(t, singleton.DB.Where("block_identifier = ?", model.BlockIDToken).First(&w).Error)
require.GreaterOrEqual(t, w.Count, uint64(3))
}
func TestRESTScope_GoodPATClearsWAFCounter(t *testing.T) {
cleanupBase, uid := setupMCPTest(t)
defer cleanupBase()
_, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(model.CtxKeyRealIPStr, "198.51.100.5")
c.Next()
})
r.GET("/api/v1/server",
apiTokenAuthMiddleware(),
restScopeMiddleware(model.ScopeInventoryRead),
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
)
ts := httptest.NewServer(r)
defer ts.Close()
_, _ = httpGetWithToken(t, ts, "/api/v1/server", "nzp_invalid_token_xxx")
var w model.WAF
require.NoError(t, singleton.DB.Where("block_identifier = ?", model.BlockIDToken).First(&w).Error)
require.GreaterOrEqual(t, w.Count, uint64(1))
code, _ := httpGetWithToken(t, ts, "/api/v1/server", plain)
require.Equal(t, 200, code)
require.ErrorContains(t,
singleton.DB.Where("block_identifier = ?", model.BlockIDToken).First(&model.WAF{}).Error,
"record not found",
)
}
@@ -0,0 +1,55 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
func TestREST_ServerConfigRequiresWriteScope(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
_, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/server/config/:id",
apiTokenAuthMiddleware(),
restScopeMiddleware(serverConfigSensitiveScope()),
func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) },
)
ts := httptest.NewServer(r)
defer ts.Close()
resp := doReq(t, ts, "GET", "/api/v1/server/config/7", plain)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode,
"nezha:server:read must not be sufficient to read agent config (contains client_secret)")
}
func TestREST_ServerConfigGrantedByWriteScope(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
_, plain := mkToken(t, uid, []string{model.ScopeServerWrite}, nil)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/server/config/:id",
apiTokenAuthMiddleware(),
restScopeMiddleware(serverConfigSensitiveScope()),
func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) },
)
ts := httptest.NewServer(r)
defer ts.Close()
resp := doReq(t, ts, "GET", "/api/v1/server/config/7", plain)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
}
@@ -0,0 +1,79 @@
package controller
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func patRequestCtx(t *testing.T, tok *model.APIToken, uid uint64, method, path string, body any) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
var rdr *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
c.Request = httptest.NewRequest(method, path, rdr)
c.Request.Header.Set("Content-Type", "application/json")
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: uid}, Role: model.RoleMember})
c.Set(apiTokenCtxKey, tok)
c.Set(model.CtxKeyAPIToken, tok)
return c, w
}
func TestREST_PATServerWhitelistBlocksOtherServer(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, []uint64{99})
srv, _ := singleton.ServerShared.Get(7)
require.NotNil(t, srv)
require.Equal(t, uid, srv.GetUserID())
c, _ := patRequestCtx(t, tok, uid, "GET", "/api/v1/server/config/7", nil)
c.Params = gin.Params{{Key: "id", Value: "7"}}
_, err := getServerConfig(c)
require.Error(t, err, "PAT not in server whitelist must be rejected")
}
func TestREST_PATServerWhitelistBlocksSetConfig(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
tok, _ := mkToken(t, uid, []string{model.ScopeServerWrite}, []uint64{99})
c, _ := patRequestCtx(t, tok, uid, "POST", "/api/v1/server/config", model.ServerConfigForm{
Servers: []uint64{7},
Config: "{}",
})
_, err := setServerConfig(c)
require.Error(t, err, "setServerConfig must reject non-whitelisted server")
}
func TestREST_PATServerWhitelistAllowsListedServer(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, []uint64{7})
c, _ := patRequestCtx(t, tok, uid, "GET", "/api/v1/server/config/7", nil)
c.Params = gin.Params{{Key: "id", Value: "7"}}
data, err := getServerConfig(c)
require.NoError(t, err)
require.Equal(t, "", data, "no agent stream connected so handler should return empty")
}
+615
View File
@@ -0,0 +1,615 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func setupAPITokenTest(t *testing.T) func() {
t.Helper()
originalDB := singleton.DB
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.APIToken{}, &model.Server{}))
singleton.DB = db
return func() {
_ = sqlDB.Close()
singleton.DB = originalDB
}
}
func ctxAsUser(uid uint64, role model.Role) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/", nil)
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: uid}, Role: role})
return c
}
func bindJSON(c *gin.Context, body any) {
b, _ := json.Marshal(body)
c.Request = httptest.NewRequest("POST", "/", bytes.NewReader(b))
c.Request.Header.Set("Content-Type", "application/json")
}
func TestCreateAPIToken_MemberCanCreateExplicitScope(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "claude",
Scopes: []string{model.ScopeServerRead},
})
res, err := createAPIToken(c)
require.NoError(t, err)
require.NotEmpty(t, res.Token)
require.True(t, strings.HasPrefix(res.Token, model.APITokenPrefix))
require.Greater(t, res.ID, uint64(0))
var stored model.APIToken
require.NoError(t, singleton.DB.First(&stored, res.ID).Error)
require.Equal(t, model.HashAPIToken(res.Token), stored.TokenHash)
require.Equal(t, uint64(10), stored.UserID)
}
func TestCreateAPIToken_MemberCannotIssueWildcard(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{model.ScopeNezhaAll},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "admin")
}
func TestCreateAPIToken_AdminCanIssueWildcard(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "ops-script",
Scopes: []string{model.ScopeNezhaAll},
})
res, err := createAPIToken(c)
require.NoError(t, err)
require.Contains(t, res.Scopes, model.ScopeNezhaAll)
}
func TestCreateAPIToken_RejectsUnknownScope(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{"mcp:hack:everything"},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown scope")
}
func TestCreateAPIToken_RejectsEmptyScopes(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{Name: "x", Scopes: []string{}})
_, err := createAPIToken(c)
require.Error(t, err)
}
func TestCreateAPIToken_RejectsTooManyServerIDs(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
ids := make([]uint64, 1001)
for i := range ids {
ids[i] = uint64(i + 1)
}
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{model.ScopeServerRead},
ServerIDs: ids,
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "too many server_ids")
}
func TestCreateAPIToken_RejectsExpirationOutOfRange(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{model.ScopeServerRead},
ExpiresInDays: -1,
})
_, err := createAPIToken(c)
require.Error(t, err)
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{model.ScopeServerRead},
ExpiresInDays: 10000,
})
_, err = createAPIToken(c)
require.Error(t, err)
}
func TestDeleteAPIToken_OnlyOwnerOrAdmin(t *testing.T) {
defer setupAPITokenTest(t)()
tok := model.APIToken{UserID: 10, Name: "x", TokenHash: model.HashAPIToken("nzp_x")}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
c := ctxAsUser(11, model.RoleMember)
c.Params = gin.Params{{Key: "id", Value: itoa(tok.ID)}}
_, err := deleteAPIToken(c)
require.Error(t, err, "other member must not delete")
c = ctxAsUser(10, model.RoleMember)
c.Params = gin.Params{{Key: "id", Value: itoa(tok.ID)}}
_, err = deleteAPIToken(c)
require.NoError(t, err)
}
func TestDeleteAPIToken_AdminCanDeleteAny(t *testing.T) {
defer setupAPITokenTest(t)()
tok := model.APIToken{UserID: 10, Name: "x", TokenHash: model.HashAPIToken("nzp_y")}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
c := ctxAsUser(1, model.RoleAdmin)
c.Params = gin.Params{{Key: "id", Value: itoa(tok.ID)}}
_, err := deleteAPIToken(c)
require.NoError(t, err)
}
// installServerForAPIToken 在 ServerShared 里塞一台属于 ownerUID 的 server,
// 仅用于 PAT 创建路径的 server_ids 权限校验测试。
func installServerForAPIToken(t *testing.T, serverID, ownerUID uint64) func() {
t.Helper()
original := singleton.ServerShared
sc := singleton.NewEmptyServerClassForTest()
srv := &model.Server{}
srv.ID = serverID
srv.SetUserID(ownerUID)
sc.InsertForTest(srv)
singleton.ServerShared = sc
return func() { singleton.ServerShared = original }
}
func TestCreateAPIToken_MemberCannotIncludeForeignServerID(t *testing.T) {
defer setupAPITokenTest(t)()
defer installServerForAPIToken(t, 42, 999)() // server 42 owned by user 999
c := ctxAsUser(10, model.RoleMember) // attacker is user 10
bindJSON(c, model.APITokenCreateRequest{
Name: "evil",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{42},
})
_, err := createAPIToken(c)
require.Error(t, err, "member must not be able to bind foreign server_id into a PAT")
require.Contains(t, err.Error(), "permission denied")
}
func TestCreateAPIToken_MemberCannotIncludeNonexistentServerID(t *testing.T) {
defer setupAPITokenTest(t)()
cleanup := installServerForAPIToken(t, 1, 10)
defer cleanup()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "evil2",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{9999}, // never-existed server
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "server not found")
}
func TestCreateAPIToken_AdminCanIncludeAnyServerID(t *testing.T) {
defer setupAPITokenTest(t)()
defer installServerForAPIToken(t, 77, 999)() // foreign server
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "ops-script",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{77},
})
res, err := createAPIToken(c)
require.NoError(t, err)
require.Equal(t, []uint64{77}, res.ServerIDs)
}
func TestCreateAPIToken_AdminCannotIncludeNonexistentServerID(t *testing.T) {
defer setupAPITokenTest(t)()
defer installServerForAPIToken(t, 77, 999)() // only server 77 exists
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "ops-future-bind",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{4242}, // never-existed server id
})
_, err := createAPIToken(c)
require.Error(t, err, "admin must not bind a PAT to a nonexistent server_id; a later-created server with that id would auto-inherit the grant")
require.Contains(t, err.Error(), "server not found")
}
func TestCreateAPIToken_MemberOwnServerIDIsAccepted(t *testing.T) {
defer setupAPITokenTest(t)()
defer installServerForAPIToken(t, 55, 10)() // user 10 owns server 55
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "self",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{55},
})
res, err := createAPIToken(c)
require.NoError(t, err)
require.Equal(t, []uint64{55}, res.ServerIDs)
}
func TestAPITokenAuthMW_ExpiredTokenRejected(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("e", 32)
past := time.Now().Add(-time.Hour)
tok := model.APIToken{
UserID: 10,
Name: "expired",
TokenHash: model.HashAPIToken(plain),
ExpiresAt: &past,
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}}).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain)
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "expired token must abort the request")
require.Equal(t, http.StatusUnauthorized, w.Code)
require.Contains(t, w.Body.String(), "expired")
}
func TestAPITokenAuthMW_OwnerDeletedRejected(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("o", 32)
tok := model.APIToken{
UserID: 999,
Name: "orphan",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain)
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "owner-less PAT must abort")
require.Equal(t, http.StatusUnauthorized, w.Code)
require.Contains(t, w.Body.String(), "owner")
}
func TestAPITokenAuthMW_HappyPathSetsUserContext(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("g", 32)
tok := model.APIToken{
UserID: 10,
Name: "good",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}, Username: "alice"}).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain)
apiTokenAuthMiddleware()(c)
require.False(t, c.IsAborted())
user, ok := c.Get(model.CtxKeyAuthorizedUser)
require.True(t, ok)
require.Equal(t, uint64(10), user.(*model.User).ID)
got := APITokenFromContext(c)
require.NotNil(t, got)
require.Equal(t, "good", got.Name)
}
func TestAPITokenAuthMW_NonNZPBearerPassesThrough(t *testing.T) {
defer setupAPITokenTest(t)()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer some-jwt-here")
apiTokenAuthMiddleware()(c)
require.False(t, c.IsAborted(), "non-nzp Bearer must pass through to JWT middleware")
require.Nil(t, APITokenFromContext(c))
}
func TestAPITokenAuthMW_EmptyNZPBodyRejected(t *testing.T) {
defer setupAPITokenTest(t)()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer nzp_")
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(),
"Bearer with empty nzp_ body must be rejected (would otherwise hash an empty string and look it up)")
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAPITokenAuthMW_RevokedTokenRejected(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("r", 32)
tok := model.APIToken{
UserID: 10,
Name: "to-revoke",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}}).Error)
require.NoError(t, singleton.DB.Delete(&model.APIToken{}, tok.ID).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain)
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "revoked PAT must abort")
require.Equal(t, http.StatusUnauthorized, w.Code)
require.Contains(t, w.Body.String(), "invalid api token")
}
func TestAPITokenAuthMW_OversizedTokenIsRejected(t *testing.T) {
defer setupAPITokenTest(t)()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer nzp_"+strings.Repeat("X", 100*1024))
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "huge nzp_ body must still abort (no DoS via lookup)")
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAPITokenAuthMW_NonASCIITokenIsRejected(t *testing.T) {
defer setupAPITokenTest(t)()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer nzp_中文😀💀")
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "non-ASCII nzp_ body must be rejected by hash lookup")
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAPITokenAuthMW_SQLInjectionAttemptIsRejected(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("s", 32)
tok := model.APIToken{
UserID: 10,
Name: "real",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer nzp_'; DROP TABLE api_tokens; --")
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "SQL-injection-shaped token must just look up and miss")
require.Equal(t, http.StatusUnauthorized, w.Code)
var stored model.APIToken
require.NoError(t, singleton.DB.First(&stored, tok.ID).Error,
"real token row must survive — GORM uses prepared statements")
}
func TestAPITokenAuthMW_LowercaseBearerPassesThrough(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("l", 32)
tok := model.APIToken{UserID: 10, Name: "x", TokenHash: model.HashAPIToken(plain)}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}}).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "bearer "+plain)
apiTokenAuthMiddleware()(c)
require.False(t, c.IsAborted(),
"lowercase 'bearer ' is not the canonical scheme; PAT mw must skip it (RFC 7235 says scheme is case-insensitive, "+
"but we deliberately match GitHub/AWS behaviour of strict 'Bearer ' to keep PAT/JWT lookup paths predictable)")
require.Nil(t, APITokenFromContext(c), "lowercase bearer must not register as PAT")
}
func TestAPITokenAuthMW_TrailingWhitespaceTolerated(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("w", 32)
tok := model.APIToken{UserID: 10, Name: "trim", TokenHash: model.HashAPIToken(plain)}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}}).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain+" ")
apiTokenAuthMiddleware()(c)
require.False(t, c.IsAborted(),
"trailing/leading whitespace around PAT must be trimmed (curl users often paste with newlines)")
require.NotNil(t, APITokenFromContext(c))
}
func TestCreateAPIToken_NameTooLongRejected(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: strings.Repeat("X", 129),
Scopes: []string{model.ScopeServerRead},
})
_, err := createAPIToken(c)
require.Error(t, err, "name >128 chars must be rejected (binding tag max=128 or handler check)")
}
func TestCreateAPIToken_EmptyNameRejected(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: " ",
Scopes: []string{model.ScopeServerRead},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "name required")
}
func TestAPIToken_DuplicateHashViolatesUniqueIndex(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("a", 32)
for _, uid := range []uint64{10, 11} {
tok := model.APIToken{
UserID: uid,
Name: "dup",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
err := singleton.DB.Create(&tok).Error
if uid == 10 {
require.NoError(t, err, "first insert must succeed")
continue
}
require.Error(t, err, "duplicate hash must violate unique index (defense against forged tokens)")
require.Contains(t, err.Error(), "UNIQUE")
}
}
func TestListAPITokens_ReturnsOnlyOwn(t *testing.T) {
defer setupAPITokenTest(t)()
for i, uid := range []uint64{10, 10, 20} {
tok := model.APIToken{UserID: uid, Name: "n", TokenHash: model.HashAPIToken("nzp_unique_" + itoa(uint64(i)))}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
}
c := ctxAsUser(10, model.RoleMember)
got, err := listAPITokens(c)
require.NoError(t, err)
require.Len(t, got, 2)
}
func itoa(v uint64) string {
return strings.TrimSpace(jsonNum(v))
}
func jsonNum(v uint64) string {
b, _ := json.Marshal(v)
return string(b)
}
// scope_doc.go and HasScope advertise nezha:<resource>:* as a first-class
// scope shape, and rest_scope_test.go pins runtime support for it. The
// create-API-token endpoint must accept those wildcards too — otherwise
// the documented surface is unreachable via the only endpoint that can
// issue PATs.
func TestCreateAPIToken_AcceptsResourceWildcardScopes(t *testing.T) {
defer setupAPITokenTest(t)()
cases := []string{
"nezha:server:*",
"nezha:service:*",
"nezha:cron:*",
"nezha:transfer:*",
}
for _, scope := range cases {
t.Run(scope, func(t *testing.T) {
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "wildcard-" + scope,
Scopes: []string{scope},
})
res, err := createAPIToken(c)
require.NoError(t, err, "resource wildcard %q must be issuable", scope)
require.Contains(t, res.Scopes, scope)
})
}
}
// nezha:admin:* is admin-only and already on AdminOnlyScopes; this test
// ensures the new wildcard acceptance does NOT widen admin-only scopes
// to members.
func TestCreateAPIToken_ResourceWildcardStillRejectsAdminOnly(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "member-tries-admin-wildcard",
Scopes: []string{"nezha:admin:*"},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "admin")
}
// Unknown resources must still be rejected even with a wildcard verb so
// that nezha:bogus:* does not become a forward-compat blank cheque.
func TestCreateAPIToken_RejectsUnknownResourceWildcard(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "bogus",
Scopes: []string{"nezha:bogus:*"},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown scope")
}
@@ -0,0 +1,71 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func callBatchMoveWithPAT(t *testing.T, callerID uint64, role model.Role, tok *model.APIToken, body string) ([]model.BatchMoveServerResult, bool, string) {
t.Helper()
r := gin.New()
r.Use(newPATCtxSetter(callerID, role, tok))
r.POST("/batch-move/server", commonHandler(batchMoveServer))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/batch-move/server", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
var resp struct {
Success bool `json:"success"`
Error string `json:"error"`
Data []model.BatchMoveServerResult `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
return resp.Data, resp.Success, resp.Error
}
func TestBatchMoveServer_AdminPATScopeNarrowsServerIDs(t *testing.T) {
cleanup := setupRetryServerTransferFixture(t)
defer cleanup()
seedServer(t, 1, 999)
seedServer(t, 2, 999)
tok := &model.APIToken{ID: 18, UserID: 999}
tok.SetServerIDs([]uint64{1})
data, ok, errStr := callBatchMoveWithPAT(t, 999, model.RoleAdmin, tok,
`{"ids":[1,2],"to_user":200}`)
assert.True(t, ok, "batch-move call must succeed at the request layer: %s", errStr)
require.Len(t, data, 2)
resultByID := map[uint64]model.BatchMoveServerResult{}
for _, r := range data {
resultByID[r.ServerID] = r
}
assert.NotEqual(t, model.BatchMoveServerResultPending, resultByID[2].Status,
"admin PAT scoped to {1} MUST NOT be able to move server 2; got status=%q error=%q",
resultByID[2].Status, resultByID[2].Error)
var pending int64
require.NoError(t, singleton.DB.Model(&model.ServerTransfer{}).
Where("server_id = ? AND status = ?", 2, model.ServerTransferStatusPending).
Count(&pending).Error)
assert.Equal(t, int64(0), pending,
"rejected batch-move of server 2 must not create a Pending row")
assert.Equal(t, model.BatchMoveServerResultPending, resultByID[1].Status,
"admin PAT scoped to {1} must still be able to move server 1; got status=%q error=%q",
resultByID[1].Status, resultByID[1].Error)
}
+190 -90
View File
@@ -8,7 +8,6 @@ import (
"log"
"net/http"
"os"
"path"
"regexp"
"slices"
"strings"
@@ -45,6 +44,8 @@ func ServeWeb(frontendDist fs.FS) http.Handler {
routers(r, frontendDist)
kickoffTransferGC()
return r
}
@@ -56,104 +57,149 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
if err := authMiddleware.MiddlewareInit(); err != nil {
log.Fatal("authMiddleware.MiddlewareInit Error:" + err.Error())
}
// Signature-only view of the auth middleware for IsDashboardCredential:
// same key and pinned algorithm, claims validation deliberately skipped.
dashboardCredentialJWTParser = newDashboardCredentialJWTParser(authMiddleware)
// /mcp — Model Context Protocol endpoint, authenticated by PAT only (闸 1 + 闸 2)。
// 不放在 /api/v1 下:MCP client 配置 URL 更短,且 MCP transport 协议演进与 REST API
// 解耦。鉴权一律走 apiTokenAuthMiddleware;不接受 JWT 以避免浏览器误触。
// mcpOriginGuard 防止 DNS rebinding / 浏览器跨站调用。
r.POST("/mcp", mcpOriginGuard(), apiTokenAuthMiddleware(), mcpEndpoint)
// Streamable HTTP 规范要求:不实现 standalone SSE / session 时,GET / DELETE
// 必须显式返回 405,让客户端走 POST-only 路径并跳过 session 终止流程。
// 不显式注册时,Gin 会走 NoRoute → fallbackToFrontend,对 MCP 客户端是 HTML/404。
r.GET("/mcp", mcpOriginGuard(), mcpMethodNotAllowed)
r.DELETE("/mcp", mcpOriginGuard(), mcpMethodNotAllowed)
r.GET("/mcp/download/:token", mcpOriginGuard(), transferDownloadHandler)
r.POST("/mcp/upload/:token", mcpOriginGuard(), transferUploadHandler)
registerAgentcompatRoutes(r)
api := r.Group("api/v1")
api.POST("/login", authMiddleware.LoginHandler)
api.GET("/oauth2/:provider", commonHandler(oauth2redirect))
r.GET("/script/:name", serveScript)
r.GET("/script/bin/:os/:arch", serveAgentBinary)
fallbackAuthMw := fallbackAuthMiddleware(authMiddleware)
fallbackAuth := api.Group("", fallbackAuthMw)
fallbackAuth.GET("/setting", commonHandler(listConfig))
fallbackAuth.GET("/oauth2/callback", commonHandler(oauth2callback(authMiddleware)))
authMw := authMiddleware.MiddlewareFunc()
optionalAuthMw := utils.IfOr(singleton.Conf.ForceAuth, authMw, fallbackAuthMw)
jwtMw := authMiddleware.MiddlewareFunc()
patMw := apiTokenAuthMiddleware()
authMw := jwtOrPATAuthMiddleware(patMw, jwtMw)
// optional 路由:ForceAuth=true 走严格 PAT-or-JWT;ForceAuth=false 走
// PAT-or-FallbackJWT,保证两种模式下 PAT 都会被解析,restScopeMiddleware
// 才能按 scope 真实收口(否则匿名 PAT 请求会被当 guest,scope 失效)。
optionalAuthMw := utils.IfOr(singleton.Conf.ForceAuth, authMw, patOrFallbackAuthMiddleware(patMw, fallbackAuthMw))
optionalAuth := api.Group("", optionalAuthMw)
optionalAuth.GET("/ws/server", commonHandler(serverStream))
optionalAuth.GET("/server-group", commonHandler(listServerGroup))
optionalAuth.GET("/ws/server", restScopeMiddleware(model.ScopeInventoryRead), commonHandler(serverStream))
optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeInventoryRead), commonHandler(listServerGroup))
optionalAuth.GET("/service", commonHandler(showService))
optionalAuth.GET("/service/:id", commonHandler(listServiceHistory))
optionalAuth.GET("/service/server", commonHandler(listServerWithServices))
optionalAuth.GET("/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(showService))
optionalAuth.GET("/service/server", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerWithServices))
optionalAuth.GET("/domains", commonHandler(GetDomainList))
optionalAuth.GET("/service/:id/history", restScopeMiddleware(model.ScopeServiceRead), commonHandler(getServiceHistory))
optionalAuth.GET("/server/:id/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerServices))
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerMetrics))
optionalAuth.GET("/server/:id/online-history", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerOnlineHistory))
auth := api.Group("", authMw)
// CSRF middleware applies group-wide. Safe methods short-circuit and
// PAT bearer requests bypass — so the only callers gated are
// cookie-JWT POST/PATCH/PUT/DELETE, which is exactly the H6 surface.
auth := api.Group("", authMw, csrfMiddleware())
auth.GET("/refresh-token", authMiddleware.RefreshHandler)
// 「自我管理」类端点 — 显式禁止 PAT 访问(避免 PAT 自我提权链)。
patForbidden := restPATForbiddenMiddleware()
auth.POST("/refresh-token", patForbidden, authMiddleware.RefreshHandler)
auth.GET("/profile", patForbidden, commonHandler(getProfile))
auth.POST("/profile", patForbidden, commonHandler(updateProfile))
auth.POST("/oauth2/:provider/unbind", patForbidden, commonHandler(unbindOauth2))
auth.GET("/api-tokens", patForbidden, commonHandler(listAPITokens))
auth.POST("/api-tokens", patForbidden, commonHandler(createAPIToken))
auth.DELETE("/api-tokens/:id", patForbidden, commonHandler(deleteAPIToken))
auth.POST("/terminal", commonHandler(createTerminal))
auth.GET("/ws/terminal/:id", commonHandler(terminalStream))
// 资源族划分:
// - nezha:inventory:* —— 对“服务器台账”的枚举与删除(列出 server / server-group、
// 删除 server / server-group)。这是管理后台清单管理动作。
auth.POST("/terminal", restScopeMiddleware(model.ScopeServerExec), commonHandler(createTerminal))
auth.GET("/ws/terminal/:id", restScopeMiddleware(model.ScopeServerExec), commonHandler(terminalStream))
auth.POST("/file", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(createFM))
auth.GET("/ws/file/:id", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(fmStream))
auth.GET("/file", commonHandler(createFM))
auth.GET("/ws/file/:id", commonHandler(fmStream))
auth.GET("/server", restScopeMiddleware(model.ScopeInventoryRead), listHandler(listServer))
auth.PATCH("/server/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServer))
auth.GET("/server/config/:id", restScopeMiddleware(serverConfigSensitiveScope()), commonHandler(getServerConfig))
auth.POST("/server/config", restScopeMiddleware(model.ScopeServerWrite), commonHandler(setServerConfig))
auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServer))
auth.POST("/batch-move/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchMoveServer))
auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer))
auth.POST("/batch-lockdown/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchLockdownOnlineServers))
auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup))
auth.PATCH("/server-group/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServerGroup))
auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServerGroup))
auth.GET("/profile", commonHandler(getProfile))
auth.POST("/profile", commonHandler(updateProfile))
auth.POST("/oauth2/:provider/unbind", commonHandler(unbindOauth2))
// transfer — 严格使用 nezha:transfer 资源族 scope(read/write/delete)。
auth.GET("/transfer", restScopeMiddleware(model.ScopeTransferRead), listHandler(listServerTransfer))
auth.POST("/transfer/:id/cancel", restScopeMiddleware(model.ScopeTransferWrite), commonHandler(cancelServerTransfer))
auth.POST("/transfer/:id/retry", restScopeMiddleware(model.ScopeTransferWrite), commonHandler(retryServerTransfer))
auth.GET("/ws/transfer", restScopeMiddleware(model.ScopeTransferRead), commonHandler(transferStream))
auth.GET("/user", adminHandler(listUser))
auth.POST("/user", adminHandler(createUser))
auth.POST("/batch-delete/user", adminHandler(batchDeleteUser))
// service monitor
auth.GET("/service/list", restScopeMiddleware(model.ScopeServiceRead), listHandler(listService))
auth.POST("/service", restScopeMiddleware(model.ScopeServiceWrite), commonHandler(createService))
auth.PATCH("/service/:id", restScopeMiddleware(model.ScopeServiceWrite), commonHandler(updateService))
auth.POST("/batch-delete/service", restScopeMiddleware(model.ScopeServiceDelete), commonHandler(batchDeleteService))
auth.GET("/service/list", listHandler(listService))
auth.POST("/service", commonHandler(createService))
auth.PATCH("/service/:id", commonHandler(updateService))
auth.POST("/batch-delete/service", commonHandler(batchDeleteService))
auth.GET("/notification-group", restScopeMiddleware(model.ScopeNotificationGroupRead), commonHandler(listNotificationGroup))
auth.POST("/notification-group", restScopeMiddleware(model.ScopeNotificationGroupWrite), commonHandler(createNotificationGroup))
auth.PATCH("/notification-group/:id", restScopeMiddleware(model.ScopeNotificationGroupWrite), commonHandler(updateNotificationGroup))
auth.POST("/batch-delete/notification-group", restScopeMiddleware(model.ScopeNotificationGroupDelete), commonHandler(batchDeleteNotificationGroup))
auth.POST("/server-group", commonHandler(createServerGroup))
auth.PATCH("/server-group/:id", commonHandler(updateServerGroup))
auth.POST("/batch-delete/server-group", commonHandler(batchDeleteServerGroup))
auth.GET("/notification", restScopeMiddleware(model.ScopeNotificationRead), listHandler(listNotification))
auth.POST("/notification", restScopeMiddleware(model.ScopeNotificationWrite), commonHandler(createNotification))
auth.PATCH("/notification/:id", restScopeMiddleware(model.ScopeNotificationWrite), commonHandler(updateNotification))
auth.POST("/batch-delete/notification", restScopeMiddleware(model.ScopeNotificationDelete), commonHandler(batchDeleteNotification))
auth.GET("/notification-group", commonHandler(listNotificationGroup))
auth.POST("/notification-group", commonHandler(createNotificationGroup))
auth.PATCH("/notification-group/:id", commonHandler(updateNotificationGroup))
auth.POST("/batch-delete/notification-group", commonHandler(batchDeleteNotificationGroup))
auth.GET("/alert-rule", restScopeMiddleware(model.ScopeAlertRuleRead), listHandler(listAlertRule))
auth.POST("/alert-rule", restScopeMiddleware(model.ScopeAlertRuleWrite), commonHandler(createAlertRule))
auth.PATCH("/alert-rule/:id", restScopeMiddleware(model.ScopeAlertRuleWrite), commonHandler(updateAlertRule))
auth.POST("/batch-delete/alert-rule", restScopeMiddleware(model.ScopeAlertRuleDelete), commonHandler(batchDeleteAlertRule))
auth.GET("/server", listHandler(listServer))
auth.PATCH("/server/:id", commonHandler(updateServer))
auth.GET("/server/config/:id", commonHandler(getServerConfig))
auth.POST("/server/config", commonHandler(setServerConfig))
auth.POST("/batch-delete/server", commonHandler(batchDeleteServer))
auth.POST("/batch-move/server", commonHandler(batchMoveServer))
auth.POST("/force-update/server", commonHandler(forceUpdateServer))
auth.GET("/cron", restScopeMiddleware(model.ScopeCronRead), listHandler(listCron))
auth.POST("/cron", restScopeMiddleware(model.ScopeCronWrite), commonHandler(createCron))
auth.PATCH("/cron/:id", restScopeMiddleware(model.ScopeCronWrite), commonHandler(updateCron))
auth.POST("/cron/:id/manual", restScopeMiddleware(model.ScopeCronExec), commonHandler(manualTriggerCron))
auth.POST("/batch-delete/cron", restScopeMiddleware(model.ScopeCronDelete), commonHandler(batchDeleteCron))
auth.GET("/notification", listHandler(listNotification))
auth.POST("/notification", commonHandler(createNotification))
auth.PATCH("/notification/:id", commonHandler(updateNotification))
auth.POST("/batch-delete/notification", commonHandler(batchDeleteNotification))
auth.GET("/ddns", restScopeMiddleware(model.ScopeDDNSRead), listHandler(listDDNS))
auth.GET("/ddns/providers", restScopeMiddleware(model.ScopeDDNSRead), commonHandler(listProviders))
auth.POST("/ddns", restScopeMiddleware(model.ScopeDDNSWrite), commonHandler(createDDNS))
auth.PATCH("/ddns/:id", restScopeMiddleware(model.ScopeDDNSWrite), commonHandler(updateDDNS))
auth.POST("/batch-delete/ddns", restScopeMiddleware(model.ScopeDDNSDelete), commonHandler(batchDeleteDDNS))
auth.GET("/alert-rule", listHandler(listAlertRule))
auth.POST("/alert-rule", commonHandler(createAlertRule))
auth.PATCH("/alert-rule/:id", commonHandler(updateAlertRule))
auth.POST("/batch-delete/alert-rule", commonHandler(batchDeleteAlertRule))
auth.GET("/nat", restScopeMiddleware(model.ScopeNATRead), listHandler(listNAT))
auth.POST("/nat", restScopeMiddleware(model.ScopeNATWrite), commonHandler(createNAT))
auth.PATCH("/nat/:id", restScopeMiddleware(model.ScopeNATWrite), commonHandler(updateNAT))
auth.POST("/batch-delete/nat", restScopeMiddleware(model.ScopeNATDelete), commonHandler(batchDeleteNAT))
auth.GET("/cron", listHandler(listCron))
auth.POST("/cron", commonHandler(createCron))
auth.PATCH("/cron/:id", commonHandler(updateCron))
auth.GET("/cron/:id/manual", commonHandler(manualTriggerCron))
auth.POST("/batch-delete/cron", commonHandler(batchDeleteCron))
auth.GET("/ddns", listHandler(listDDNS))
auth.GET("/ddns/providers", commonHandler(listProviders))
auth.POST("/ddns", commonHandler(createDDNS))
auth.PATCH("/ddns/:id", commonHandler(updateDDNS))
auth.POST("/batch-delete/ddns", commonHandler(batchDeleteDDNS))
auth.GET("/nat", listHandler(listNAT))
auth.POST("/nat", commonHandler(createNAT))
auth.PATCH("/nat/:id", commonHandler(updateNAT))
auth.POST("/batch-delete/nat", commonHandler(batchDeleteNAT))
auth.GET("/waf", pCommonHandler(listBlockedAddress))
auth.POST("/batch-delete/waf", adminHandler(batchDeleteBlockedAddress))
auth.GET("/online-user", pCommonHandler(listOnlineUser))
auth.POST("/online-user/batch-block", adminHandler(batchBlockOnlineUser))
auth.PATCH("/setting", adminHandler(updateConfig))
// 管理员资源 — 仅 nezha:* / nezha:admin:* 持有者可调(adminHandler 进一步校验 user.Role)。
auth.GET("/user", restScopeMiddleware(model.ScopeAdminAll), adminHandler(listUser))
auth.POST("/user", restScopeMiddleware(model.ScopeAdminAll), adminHandler(createUser))
auth.POST("/batch-delete/user", restScopeMiddleware(model.ScopeAdminAll), adminHandler(batchDeleteUser))
auth.GET("/waf", restScopeMiddleware(model.ScopeAdminAll), pAdminHandler(listBlockedAddress))
auth.POST("/batch-delete/waf", restScopeMiddleware(model.ScopeAdminAll), adminHandler(batchDeleteBlockedAddress))
auth.GET("/online-user", restScopeMiddleware(model.ScopeAdminAll), pAdminHandler(listOnlineUser))
auth.POST("/online-user/batch-block", restScopeMiddleware(model.ScopeAdminAll), adminHandler(batchBlockOnlineUser))
auth.PATCH("/setting", restScopeMiddleware(model.ScopeAdminAll), adminHandler(updateConfig))
auth.POST("/maintenance", restScopeMiddleware(model.ScopeAdminAll), adminHandler(runMaintenance))
auth.POST("/domains", commonHandler(AddDomain))
auth.POST("/domains/:id/verify", commonHandler(VerifyDomain))
auth.POST("/domains/:id/sync", commonHandler(SyncDomainWHOIS))
auth.POST("/domains/sync-all", commonHandler(SyncAllDomains))
auth.PUT("/domains/:id", commonHandler(UpdateDomain))
auth.DELETE("/domains/:id", commonHandler(DeleteDomain))
@@ -288,6 +334,29 @@ func pCommonHandler[S ~[]E, E any](handler pHandlerFunc[S, E]) func(*gin.Context
}
}
func pAdminHandler[S ~[]E, E any](handler pHandlerFunc[S, E]) func(*gin.Context) {
return func(c *gin.Context) {
auth, ok := c.Get(model.CtxKeyAuthorizedUser)
if !ok {
c.JSON(http.StatusOK, newErrorResponse(singleton.Localizer.ErrorT("unauthorized")))
return
}
user := *auth.(*model.User)
if !user.Role.IsAdmin() {
c.JSON(http.StatusOK, newErrorResponse(singleton.Localizer.ErrorT("permission denied")))
return
}
data, err := handler(c)
if err != nil {
c.JSON(http.StatusOK, newErrorResponse(err))
return
}
c.JSON(http.StatusOK, model.PaginatedResponse[S, E]{Success: true, Data: data})
}
}
func filter[S ~[]E, E model.CommonInterface](ctx *gin.Context, s S) S {
return slices.DeleteFunc(s, func(e E) bool {
return !e.HasPermission(ctx)
@@ -300,27 +369,52 @@ func getUid(c *gin.Context) uint64 {
}
func fallbackToFrontend(frontendDist fs.FS) func(*gin.Context) {
checkLocalFileOrFs := func(c *gin.Context, fs fs.FS, path string, customStatusCode int) bool {
if _, err := os.Stat(path); err == nil {
http.ServeFile(utils.NewGinCustomWriter(c, customStatusCode), c.Request, path)
return true
}
f, err := fs.Open(path)
if err != nil {
return false
}
defer f.Close()
fileStat, err := f.Stat()
serveFile := func(c *gin.Context, name string, file fs.File, customStatusCode int) bool {
defer file.Close()
fileStat, err := file.Stat()
if err != nil {
return false
}
if fileStat.IsDir() {
return false
}
http.ServeContent(utils.NewGinCustomWriter(c, customStatusCode), c.Request, path, fileStat.ModTime(), f.(io.ReadSeeker))
readSeeker, ok := file.(io.ReadSeeker)
if !ok {
return false
}
http.ServeContent(utils.NewGinCustomWriter(c, customStatusCode), c.Request, name, fileStat.ModTime(), readSeeker)
return true
}
checkLocalFileOrFs := func(c *gin.Context, frontendFS fs.FS, templateRoot, filePath string, customStatusCode int) bool {
if filePath != "" {
localRoot, err := os.OpenRoot(templateRoot)
if err == nil {
defer localRoot.Close()
// URL paths must stay inside the selected template root; never join them against the process cwd.
if file, err := localRoot.Open(filePath); err == nil && serveFile(c, filePath, file, customStatusCode) {
return true
}
}
}
if !fs.ValidPath(filePath) {
return false
}
templateFS, err := fs.Sub(frontendFS, templateRoot)
if err != nil {
return false
}
file, err := templateFS.Open(filePath)
if err != nil {
return false
}
if serveFile(c, filePath, file, customStatusCode) {
return true
}
return false
}
frontendPageUrlRegistry := []*regexp.Regexp{
// official user frontend
regexp.MustCompile(`^/$`),
@@ -341,6 +435,12 @@ func fallbackToFrontend(frontendDist fs.FS) func(*gin.Context) {
regexp.MustCompile(`^/dashboard/settings/user$`),
regexp.MustCompile(`^/dashboard/settings/online-user$`),
regexp.MustCompile(`^/dashboard/settings/waf$`),
regexp.MustCompile(`^/dashboard/settings/api-tokens$`),
// 注意:这里的白名单决定哪些 URL 走 index.html fallback;漏一条就会把
// 直接刷新该页面变成 404(HTTP 状态码层面,body 仍是 index.html,所以
// 浏览器内 SPA 看起来正常,但 monitoring / 链接预览会以为站点挂了)。
// 新增前端路由时必须在 admin-frontend/src/main.tsx 与这里同步加。
regexp.MustCompile(`^/dashboard/transfer$`),
}
getFallbackStatusCode := func(path string) int {
@@ -365,22 +465,22 @@ func fallbackToFrontend(frontendDist fs.FS) func(*gin.Context) {
}
fallbackStatusCode := getFallbackStatusCode(c.Request.URL.Path)
if strings.HasPrefix(c.Request.URL.Path, "/dashboard") {
stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard")
localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath)
if checkLocalFileOrFs(c, frontendDist, localFilePath, http.StatusOK) {
// Only /dashboard/ belongs to the admin frontend; /dashboard.. must not be trimmed into ../.
if strings.HasPrefix(c.Request.URL.Path, "/dashboard/") {
stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard/")
if checkLocalFileOrFs(c, frontendDist, singleton.Conf.AdminTemplate, stripPath, http.StatusOK) {
return
}
if !checkLocalFileOrFs(c, frontendDist, singleton.Conf.AdminTemplate+"/index.html", fallbackStatusCode) {
if !checkLocalFileOrFs(c, frontendDist, singleton.Conf.AdminTemplate, "index.html", fallbackStatusCode) {
c.JSON(http.StatusNotFound, newErrorResponse(errors.New("404 Not Found")))
}
return
}
localFilePath := path.Join(singleton.Conf.UserTemplate, c.Request.URL.Path)
if checkLocalFileOrFs(c, frontendDist, localFilePath, http.StatusOK) {
stripPath := strings.TrimPrefix(c.Request.URL.Path, "/")
if checkLocalFileOrFs(c, frontendDist, singleton.Conf.UserTemplate, stripPath, http.StatusOK) {
return
}
if !checkLocalFileOrFs(c, frontendDist, singleton.Conf.UserTemplate+"/index.html", fallbackStatusCode) {
if !checkLocalFileOrFs(c, frontendDist, singleton.Conf.UserTemplate, "index.html", fallbackStatusCode) {
c.JSON(http.StatusNotFound, newErrorResponse(errors.New("404 Not Found")))
}
}
@@ -0,0 +1,283 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func TestGetProfile_RedactsPasswordHash(t *testing.T) {
defer setupTenancyTest(t)()
require.NoError(t, singleton.DB.AutoMigrate(&model.Oauth2Bind{}))
passwordHash := "$2a$10$profilePasswordHashMustNotLeak012345678901"
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/profile", nil)
c.Set(model.CtxKeyAuthorizedUser, &model.User{
Common: model.Common{ID: 10},
Username: "alice",
Password: passwordHash,
Role: model.RoleMember,
})
commonHandler(getProfile)(c)
require.Equal(t, http.StatusOK, w.Code)
require.NotContains(t, w.Body.String(), passwordHash)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, true, body["success"])
data, ok := body["data"].(map[string]any)
require.True(t, ok)
require.Equal(t, "alice", data["username"])
require.NotContains(t, data, "password")
}
func TestUpdateProfile_UsesStoredPasswordHashAfterRedaction(t *testing.T) {
defer setupTenancyTest(t)()
require.NoError(t, singleton.DB.AutoMigrate(&model.Oauth2Bind{}, &model.JWTSession{}))
originalUserInfoMap := singleton.UserInfoMap
originalAgentSecretToUserID := singleton.AgentSecretToUserId
singleton.UserInfoMap = make(map[uint64]model.UserInfo)
singleton.AgentSecretToUserId = make(map[string]uint64)
defer func() {
singleton.UserInfoMap = originalUserInfoMap
singleton.AgentSecretToUserId = originalAgentSecretToUserID
}()
oldHash, err := bcrypt.GenerateFromPassword([]byte("old-password"), bcrypt.MinCost)
require.NoError(t, err)
user := model.User{
Common: model.Common{ID: 10},
Username: "alice",
Password: string(oldHash),
Role: model.RoleMember,
AgentSecret: "profile-update-agent-secret",
}
require.NoError(t, singleton.DB.Create(&user).Error)
_, err = updateProfile(profileUpdateContext(t, user, model.ProfileForm{
OriginalPassword: "wrong-password",
NewUsername: "mallory",
NewPassword: "new-password",
}))
require.Error(t, err)
var unchanged model.User
require.NoError(t, singleton.DB.First(&unchanged, user.ID).Error)
require.Equal(t, "alice", unchanged.Username)
require.Equal(t, string(oldHash), unchanged.Password)
_, err = updateProfile(profileUpdateContext(t, user, model.ProfileForm{
OriginalPassword: "old-password",
NewUsername: "alice-renamed",
NewPassword: "new-password",
}))
require.NoError(t, err)
var after model.User
require.NoError(t, singleton.DB.First(&after, user.ID).Error)
require.Equal(t, "alice-renamed", after.Username)
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(after.Password), []byte("new-password")))
}
func profileUpdateContext(t *testing.T, user model.User, form model.ProfileForm) *gin.Context {
t.Helper()
body, err := json.Marshal(form)
require.NoError(t, err)
c := ctxAs(user.ID, user.Role)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/profile", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(model.CtxKeyAuthorizedUser, &user)
return c
}
func TestListDDNS_RedactsCredentials(t *testing.T) {
defer setupTenancyTest(t)()
p := model.DDNSProfile{
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "cloudflare",
AccessID: "id",
AccessSecret: "super-secret-token",
WebhookURL: "https://ddns.example/update?token=embedded-secret",
WebhookRequestBody: `{"token":"embedded-secret"}`,
WebhookHeaders: `{"Authorization":"Bearer xxx"}`,
}
require.NoError(t, singleton.DB.Create(&p).Error)
singleton.DDNSShared.InsertForTest(&p)
c := ctxAs(10, model.RoleAdmin)
out, err := listDDNS(c)
require.NoError(t, err)
require.Len(t, out, 1)
require.Empty(t, out[0].AccessSecret, "access_secret must be redacted in list response")
require.Empty(t, out[0].WebhookURL, "webhook_url must be redacted in list response")
require.Empty(t, out[0].WebhookRequestBody, "webhook_request_body must be redacted in list response")
require.Empty(t, out[0].WebhookHeaders, "webhook_headers must be redacted in list response")
require.Equal(t, "id", out[0].AccessID, "non-secret fields must be preserved")
var stored model.DDNSProfile
require.NoError(t, singleton.DB.First(&stored, p.ID).Error)
require.Equal(t, "super-secret-token", stored.AccessSecret, "redaction must not mutate stored data")
require.Equal(t, "https://ddns.example/update?token=embedded-secret", stored.WebhookURL,
"redaction must not mutate stored webhook URL")
require.Equal(t, `{"token":"embedded-secret"}`, stored.WebhookRequestBody,
"redaction must not mutate stored webhook body")
}
func TestListNotification_RedactsCredentials(t *testing.T) {
defer setupTenancyTest(t)()
n := model.Notification{
Common: model.Common{UserID: 10},
Name: "slack",
URL: "https://hooks.slack.com/services/T/B/secret",
RequestHeader: `{"Authorization":"Bearer xxx"}`,
RequestBody: `{"text":"#NEZHA#"}`,
}
require.NoError(t, singleton.DB.Create(&n).Error)
singleton.NotificationShared.InsertForTest(&n)
c := ctxAs(10, model.RoleAdmin)
out, err := listNotification(c)
require.NoError(t, err)
require.Len(t, out, 1)
require.Empty(t, out[0].URL, "url must be redacted in list response")
require.Empty(t, out[0].RequestHeader, "request_header must be redacted in list response")
require.Empty(t, out[0].RequestBody, "request_body must be redacted in list response")
require.Equal(t, "slack", out[0].Name, "non-secret fields must be preserved")
var stored model.Notification
require.NoError(t, singleton.DB.First(&stored, n.ID).Error)
require.Equal(t, "https://hooks.slack.com/services/T/B/secret", stored.URL,
"redaction must not mutate stored data")
}
func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) {
defer setupTenancyTest(t)()
existing := model.DDNSProfile{
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "webhook",
AccessID: "id",
AccessSecret: "keep-me",
WebhookURL: "http://127.0.0.1/",
WebhookRequestBody: `{"X-Token":"keep-body"}`,
WebhookMethod: 1,
WebhookHeaders: `{"X-Token":"keep-header"}`,
}
require.NoError(t, singleton.DB.Create(&existing).Error)
singleton.DDNSShared.InsertForTest(&existing)
c := ctxAsMemberWithBody(10, map[string]any{
"name": "cf-renamed",
"provider": "webhook",
"access_id": "id",
"access_secret": "",
"webhook_url": "",
"webhook_method": 1,
"webhook_request_type": 1,
"webhook_headers": "",
"webhook_request_body": "",
"max_retries": 3,
})
c.Params = gin.Params{{Key: "id", Value: itoa(existing.ID)}}
_, err := updateDDNS(c)
require.NoError(t, err)
var after model.DDNSProfile
require.NoError(t, singleton.DB.First(&after, existing.ID).Error)
require.Equal(t, "cf-renamed", after.Name, "non-secret edits must apply")
require.Equal(t, "keep-me", after.AccessSecret, "empty submitted secret must preserve stored value")
require.Equal(t, `{"X-Token":"keep-header"}`, after.WebhookHeaders,
"empty submitted headers must preserve stored value")
require.Equal(t, "http://127.0.0.1/", after.WebhookURL,
"empty submitted webhook URL must preserve stored value")
require.Equal(t, `{"X-Token":"keep-body"}`, after.WebhookRequestBody,
"empty submitted webhook body must preserve stored value")
}
func TestUpdateDDNS_NonEmptySecretOverwrites(t *testing.T) {
defer setupTenancyTest(t)()
existing := model.DDNSProfile{
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "webhook",
AccessSecret: "old",
WebhookURL: "http://127.0.0.1/",
WebhookMethod: 1,
}
require.NoError(t, singleton.DB.Create(&existing).Error)
singleton.DDNSShared.InsertForTest(&existing)
c := ctxAsMemberWithBody(10, map[string]any{
"name": "cf",
"provider": "webhook",
"access_secret": "new-secret",
"webhook_url": "http://127.0.0.1/",
"webhook_method": 1,
"webhook_request_type": 1,
"max_retries": 3,
})
c.Params = gin.Params{{Key: "id", Value: itoa(existing.ID)}}
_, err := updateDDNS(c)
require.NoError(t, err)
var after model.DDNSProfile
require.NoError(t, singleton.DB.First(&after, existing.ID).Error)
require.Equal(t, "new-secret", after.AccessSecret, "non-empty submitted secret must overwrite")
}
func TestUpdateNotification_EmptyFieldsPreserveStored(t *testing.T) {
defer setupTenancyTest(t)()
existing := model.Notification{
Common: model.Common{UserID: 10},
Name: "slack",
URL: "https://hooks.slack.com/services/keep",
RequestMethod: model.NotificationRequestMethodGET,
RequestType: model.NotificationRequestTypeJSON,
RequestHeader: `{"Authorization":"keep"}`,
RequestBody: "",
}
require.NoError(t, singleton.DB.Create(&existing).Error)
singleton.NotificationShared.InsertForTest(&existing)
c := ctxAsMemberWithBody(10, map[string]any{
"name": "slack-renamed",
"url": "",
"request_method": model.NotificationRequestMethodGET,
"request_type": model.NotificationRequestTypeJSON,
"request_header": "",
"request_body": "",
"skip_check": true,
})
c.Params = gin.Params{{Key: "id", Value: itoa(existing.ID)}}
_, err := updateNotification(c)
require.NoError(t, err)
var after model.Notification
require.NoError(t, singleton.DB.First(&after, existing.ID).Error)
require.Equal(t, "slack-renamed", after.Name, "non-secret edits must apply")
require.Equal(t, "https://hooks.slack.com/services/keep", after.URL,
"empty submitted url must preserve stored value")
require.Equal(t, `{"Authorization":"keep"}`, after.RequestHeader,
"empty submitted request_header must preserve stored value")
}
+49 -5
View File
@@ -50,10 +50,22 @@ func createCron(c *gin.Context) (uint64, error) {
return 0, err
}
if !singleton.ServerShared.CheckPermission(c, slices.Values(cf.Servers)) {
if !isValidCronCover(cf.Cover) {
return 0, singleton.Localizer.ErrorT("permission denied")
}
if err := checkCronServerListPermission(c, cf.Cover, cf.Servers, getUid(c)); err != nil {
return 0, err
}
if err := rejectImplicitCoverForLimitedPAT(c, cf.Cover, cf.Servers); err != nil {
return 0, err
}
if err := assertOwnsNotificationGroup(c, cf.NotificationGroupID); err != nil {
return 0, err
}
cr.UserID = getUid(c)
cr.TaskType = cf.TaskType
cr.Name = cf.Name
@@ -68,7 +80,6 @@ func createCron(c *gin.Context) (uint64, error) {
return 0, singleton.Localizer.ErrorT("scheduled tasks cannot be triggered by alarms")
}
// 对于计划任务类型,需要更新CronJob
var err error
if cf.TaskType == model.CronTypeCronTask {
if cr.CronJobID, err = singleton.CronShared.AddFunc(cr.Scheduler, singleton.CronTrigger(&cr)); err != nil {
@@ -108,8 +119,8 @@ func updateCron(c *gin.Context) (any, error) {
return 0, err
}
if !singleton.ServerShared.CheckPermission(c, slices.Values(cf.Servers)) {
return 0, singleton.Localizer.ErrorT("permission denied")
if !isValidCronCover(cf.Cover) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
var cr model.Cron
@@ -121,6 +132,18 @@ func updateCron(c *gin.Context) (any, error) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
if err := checkCronServerListPermission(c, cf.Cover, cf.Servers, cr.GetUserID()); err != nil {
return nil, err
}
if err := rejectImplicitCoverForLimitedPATWithOwner(c, cf.Cover, cf.Servers, cr.GetUserID()); err != nil {
return nil, err
}
if err := assertOwnsNotificationGroup(c, cf.NotificationGroupID); err != nil {
return nil, err
}
cr.TaskType = cf.TaskType
cr.Name = cf.Name
cr.Scheduler = cf.Scheduler
@@ -159,7 +182,7 @@ func updateCron(c *gin.Context) (any, error) {
// @param id path uint true "Task ID"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /cron/{id}/manual [get]
// @Router /cron/{id}/manual [post]
func manualTriggerCron(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
@@ -176,6 +199,14 @@ func manualTriggerCron(c *gin.Context) (any, error) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
// 运行时回放写侧 rejectImplicitCoverForLimitedPAT* 同一条 PAT 收口:
// 历史脏数据 / 旁路写入的 cron 仍可能携带「CronCoverAll + 不充分 deny-list」
// 的配置;CronTrigger 没有 PAT 上下文,manualTrigger 这里是唯一阻止
// 受限 PAT 触发 fan-out 到白名单外 owner servers 的同步入口。
if err := enforcePATCronDispatchScope(c, cr); err != nil {
return nil, err
}
singleton.ManualTrigger(cr)
return nil, nil
}
@@ -201,6 +232,19 @@ func batchDeleteCron(c *gin.Context) (any, error) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
// 与 manualTriggerCron 对称:删除会改变 fan-out 范围本身,受限 PAT 不
// 应通过删除一个白名单内的「掩护」cron 间接放大对白名单外 owner servers
// 的影响。回放同一条 cover-fanout 收口。
for _, id := range cr {
existing, ok := singleton.CronShared.Get(id)
if !ok || existing == nil {
continue
}
if err := enforcePATCronDispatchScope(c, existing); err != nil {
return nil, err
}
}
if err := singleton.DB.Unscoped().Delete(&model.Cron{}, "id in (?)", cr).Error; err != nil {
return nil, newGormError("%v", err)
}
@@ -0,0 +1,54 @@
package controller
import (
"testing"
"github.com/nezhahq/nezha/model"
)
// C2 regression: writes must reject unknown Cover values so dirty configs
// cannot be persisted. CronTrigger has no PAT context on the periodic
// scheduler path, so unknown Cover sails past every PAT guard and dispatches
// via the default branch in CronTrigger (no CoverAll/IgnoreAll match → still
// reaches every server that passes cronCanSendToServer).
func TestIsValidCronCover_RejectsUnknown(t *testing.T) {
cases := []struct {
name string
cover uint8
want bool
}{
{"CoverIgnoreAll", model.CronCoverIgnoreAll, true},
{"CoverAll", model.CronCoverAll, true},
{"CoverAlertTrigger", model.CronCoverAlertTrigger, true},
{"unknown_99", 99, false},
{"unknown_max", 255, false},
{"unknown_3", 3, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isValidCronCover(tc.cover); got != tc.want {
t.Fatalf("isValidCronCover(%d) = %v, want %v", tc.cover, got, tc.want)
}
})
}
}
func TestIsValidServiceCover_RejectsUnknown(t *testing.T) {
cases := []struct {
name string
cover uint8
want bool
}{
{"ServiceCoverAll", model.ServiceCoverAll, true},
{"ServiceCoverIgnoreAll", model.ServiceCoverIgnoreAll, true},
{"unknown_99", 99, false},
{"unknown_max", 255, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isValidServiceCover(tc.cover); got != tc.want {
t.Fatalf("isValidServiceCover(%d) = %v, want %v", tc.cover, got, tc.want)
}
})
}
}
@@ -0,0 +1,225 @@
package controller
// 回归 cron 运行时入口 (manualTriggerCron / batchDeleteCron) 上的 PAT
// cover-fanout 收口。配合 permissions_cover_fanout_test.go 的底座单测,
// 形成「共享底座 ↔ 资源专用入口」两层钉子,任何后续重构(例如把 guard
// 拆出 controller、把 cover 模式合并/拆分)都必须保留:
// - 受限 PAT 不能通过 manualTrigger 触发一个 deny-list 不充分的
// CronCoverAll → 否则 CronTrigger fan out 到白名单外 owner servers。
// - 受限 PAT 不能通过 batchDelete 删除同样形态的 cron → 否则相当于
// 间接操作白名单外 owner servers 的调度策略。
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
// setupCronDispatchPATFixture 与 setupCoverPATFixture 同一拓扑:alice
// (uid=100) 拥有 server 1 / server 2;下游测试给出 PAT server_ids=[1]。
func setupCronDispatchPATFixture(t *testing.T) {
t.Helper()
originalDB := singleton.DB
originalCache := singleton.Cache
originalLoc := singleton.Loc
originalLocalizer := singleton.Localizer
originalCron := singleton.CronShared
originalServer := singleton.ServerShared
originalUserInfo := singleton.UserInfoMap
originalNotification := singleton.NotificationShared
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Cron{}, &model.Server{}, &model.User{}, &model.NotificationGroup{}, &model.Notification{}))
singleton.DB = db
singleton.Loc = time.UTC
singleton.Cache = cache.New(time.Minute, time.Minute)
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
// CronTrigger 的 fan-out 路径会在 server 没接入 task-stream 时调
// NotificationShared.SendNotification 上报「离线」;这里给出一个空的
// notification class,避免 nil deref。本测试不验证通知内容。
singleton.NotificationShared = singleton.NewNotificationClass()
sc := singleton.NewEmptyServerClassForTest()
for _, id := range []uint64{1, 2} {
s := &model.Server{}
s.ID = id
s.SetUserID(100)
sc.InsertForTest(s)
}
singleton.ServerShared = sc
singleton.CronShared = singleton.NewCronClass()
singleton.UserLock.Lock()
singleton.UserInfoMap = map[uint64]model.UserInfo{100: {Role: model.RoleMember}}
singleton.UserLock.Unlock()
t.Cleanup(func() {
// Test-owned cron jobs must be joined before restoring process-global singleton dependencies.
singleton.CronShared.Close()
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Cache = originalCache
singleton.Loc = originalLoc
singleton.Localizer = originalLocalizer
singleton.CronShared = originalCron
singleton.ServerShared = originalServer
singleton.NotificationShared = originalNotification
singleton.UserLock.Lock()
singleton.UserInfoMap = originalUserInfo
singleton.UserLock.Unlock()
})
}
func insertCronForDispatchTest(t *testing.T, cover uint8, servers []uint64) uint64 {
t.Helper()
cr := &model.Cron{
Common: model.Common{UserID: 100},
Name: "dispatch-fixture",
TaskType: model.CronTypeCronTask,
Command: "echo dispatch",
Servers: servers,
Cover: cover,
}
require.NoError(t, singleton.DB.Create(cr).Error)
singleton.CronShared.Update(cr)
return cr.ID
}
func newCronDispatchRouter(t *testing.T, tok *model.APIToken) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
setAuthUser(c, 100, model.RoleMember)
if tok != nil {
c.Set(model.CtxKeyAPIToken, tok)
c.Set(apiTokenCtxKey, tok)
}
c.Next()
})
r.POST("/api/v1/cron/:id/manual", commonHandler(manualTriggerCron))
r.POST("/api/v1/batch-delete/cron", commonHandler(batchDeleteCron))
return r
}
func TestManualTriggerCron_RejectsCoverAllWithInsufficientDenyForLimitedPAT(t *testing.T) {
setupCronDispatchPATFixture(t)
cronID := insertCronForDispatchTest(t, model.CronCoverAll, []uint64{1})
tok := &model.APIToken{ID: 17, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := newCronDispatchRouter(t, tok)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost,
"/api/v1/cron/"+strconv.FormatUint(cronID, 10)+"/manual", nil)
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.False(t, success,
"PAT [1] must NOT manually trigger a CronCoverAll cron whose deny-list does not cover owner server 2; CronTrigger would fan out to it")
assert.Contains(t, errMsg, "permission denied")
}
func TestManualTriggerCron_AllowsCoverAllWhenDenyCoversNonWhitelisted(t *testing.T) {
setupCronDispatchPATFixture(t)
cronID := insertCronForDispatchTest(t, model.CronCoverAll, []uint64{2})
tok := &model.APIToken{ID: 18, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := newCronDispatchRouter(t, tok)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost,
"/api/v1/cron/"+strconv.FormatUint(cronID, 10)+"/manual", nil)
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.True(t, success,
"CronCoverAll whose deny-list covers every non-whitelisted owner server must remain triggerable: error=%s", errMsg)
}
func TestManualTriggerCron_AllowsCoverIgnoreAllInsideWhitelist(t *testing.T) {
setupCronDispatchPATFixture(t)
cronID := insertCronForDispatchTest(t, model.CronCoverIgnoreAll, []uint64{1})
tok := &model.APIToken{ID: 19, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := newCronDispatchRouter(t, tok)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost,
"/api/v1/cron/"+strconv.FormatUint(cronID, 10)+"/manual", nil)
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.True(t, success,
"CronCoverIgnoreAll allow-list inside PAT whitelist must trigger normally: error=%s", errMsg)
}
func TestBatchDeleteCron_RejectsCoverAllWithInsufficientDenyForLimitedPAT(t *testing.T) {
setupCronDispatchPATFixture(t)
cronID := insertCronForDispatchTest(t, model.CronCoverAll, []uint64{1})
tok := &model.APIToken{ID: 21, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := newCronDispatchRouter(t, tok)
body, _ := json.Marshal([]uint64{cronID})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/batch-delete/cron", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.False(t, success,
"PAT [1] must NOT batch-delete a CronCoverAll cron whose deny-list does not cover owner server 2")
assert.Contains(t, errMsg, "permission denied")
var rows []model.Cron
require.NoError(t, singleton.DB.Find(&rows).Error)
assert.Len(t, rows, 1, "cron row must still exist when the delete call is rejected")
}
func TestBatchDeleteCron_AllowsCoverAllWhenDenyCoversNonWhitelisted(t *testing.T) {
setupCronDispatchPATFixture(t)
cronID := insertCronForDispatchTest(t, model.CronCoverAll, []uint64{2})
tok := &model.APIToken{ID: 22, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := newCronDispatchRouter(t, tok)
body, _ := json.Marshal([]uint64{cronID})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/batch-delete/cron", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.True(t, success,
"deny-list covering every non-whitelisted owner server must allow batch-delete: error=%s", errMsg)
var rows []model.Cron
require.NoError(t, singleton.DB.Find(&rows).Error)
assert.Empty(t, rows, "cron row must be deleted when the call succeeds")
}
@@ -0,0 +1,65 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
func newCronListPATRouter(t *testing.T, tok *model.APIToken) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
setAuthUser(c, 100, model.RoleMember)
if tok != nil {
c.Set(model.CtxKeyAPIToken, tok)
c.Set(apiTokenCtxKey, tok)
}
c.Next()
})
r.GET("/api/v1/cron", listHandler(listCron))
return r
}
// GET /api/v1/cron must replay the same deny-list rule the dispatch guards
// use; otherwise a stale or out-of-band-written CronCoverAll row whose
// Servers deny-list does not cover the non-whitelisted owner server still
// shows up in the limited PAT's list view.
func TestListCron_HidesCoverAllWithInsufficientDenyForLimitedPAT(t *testing.T) {
setupCronDispatchPATFixture(t)
insufficient := insertCronForDispatchTest(t, model.CronCoverAll, []uint64{1})
sufficient := insertCronForDispatchTest(t, model.CronCoverAll, []uint64{2})
tok := &model.APIToken{ID: 23, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := newCronListPATRouter(t, tok)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/cron", nil)
r.ServeHTTP(w, req)
var resp struct {
Success bool `json:"success"`
Error string `json:"error"`
Data []*model.Cron `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp.Success, resp.Error)
seen := map[uint64]bool{}
for _, c := range resp.Data {
seen[c.ID] = true
}
assert.False(t, seen[insufficient],
"PAT [1] must NOT see a CronCoverAll whose deny-list does not cover owner server 2 (rows=%+v)", resp.Data)
assert.True(t, seen[sufficient],
"PAT [1] must still see a CronCoverAll whose deny-list already covers every non-whitelisted owner server")
}
@@ -0,0 +1,109 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
func setupCronManualTriggerFixture(t *testing.T) {
t.Helper()
originalDB := singleton.DB
originalCache := singleton.Cache
originalLoc := singleton.Loc
originalLocalizer := singleton.Localizer
originalCron := singleton.CronShared
originalServer := singleton.ServerShared
originalUserInfo := singleton.UserInfoMap
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Cron{}, &model.Server{}, &model.User{}))
singleton.DB = db
singleton.Loc = time.UTC
singleton.Cache = cache.New(time.Minute, time.Minute)
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
singleton.ServerShared = singleton.NewServerClass()
singleton.CronShared = singleton.NewCronClass()
singleton.UserLock.Lock()
singleton.UserInfoMap = map[uint64]model.UserInfo{100: {Role: model.RoleMember}}
singleton.UserLock.Unlock()
cr := &model.Cron{
Common: model.Common{ID: 7, UserID: 100},
Name: "victim cron",
TaskType: model.CronTypeCronTask,
Command: "echo csrf-poc",
Cover: model.CronCoverIgnoreAll,
}
require.NoError(t, db.Create(cr).Error)
singleton.CronShared.Update(cr)
t.Cleanup(func() {
singleton.CronShared.Close()
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Cache = originalCache
singleton.Loc = originalLoc
singleton.Localizer = originalLocalizer
singleton.CronShared = originalCron
singleton.ServerShared = originalServer
singleton.UserLock.Lock()
singleton.UserInfoMap = originalUserInfo
singleton.UserLock.Unlock()
})
}
func newCronManualRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(model.CtxKeyAuthorizedUser, &model.User{
Common: model.Common{ID: 100},
Role: model.RoleMember,
})
c.Next()
})
r.POST("/api/v1/cron/:id/manual", commonHandler(manualTriggerCron))
return r
}
func TestCronManualTriggerRejectsCrossSiteGET(t *testing.T) {
setupCronManualTriggerFixture(t)
r := newCronManualRouter()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/cron/7/manual", nil)
req.Header.Set("Origin", "https://attacker.example")
req.Header.Set("Sec-Fetch-Site", "cross-site")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code, "manual trigger must reject cross-site GET — the route is POST-only after the CSRF fix")
}
func TestCronManualTriggerAcceptsSameSitePOST(t *testing.T) {
setupCronManualTriggerFixture(t)
r := newCronManualRouter()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/cron/7/manual", nil)
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.True(t, success, "owner POST must succeed: error=%q", errMsg)
}
@@ -0,0 +1,165 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
func setupCronPATWhitelistFixture(t *testing.T) (cronID7, cronID8 uint64) {
t.Helper()
originalDB := singleton.DB
originalCache := singleton.Cache
originalLoc := singleton.Loc
originalLocalizer := singleton.Localizer
originalCron := singleton.CronShared
originalServer := singleton.ServerShared
originalUserInfo := singleton.UserInfoMap
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Cron{}, &model.Server{}, &model.User{}))
singleton.DB = db
singleton.Loc = time.UTC
singleton.Cache = cache.New(time.Minute, time.Minute)
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
singleton.ServerShared = singleton.NewServerClass()
singleton.CronShared = singleton.NewCronClass()
singleton.UserLock.Lock()
singleton.UserInfoMap = map[uint64]model.UserInfo{100: {Role: model.RoleMember}}
singleton.UserLock.Unlock()
cr7 := &model.Cron{
Common: model.Common{UserID: 100},
Name: "cron-on-server-1",
TaskType: model.CronTypeCronTask,
Command: "echo s1",
Servers: []uint64{1},
Cover: model.CronCoverIgnoreAll,
}
cr8 := &model.Cron{
Common: model.Common{UserID: 100},
Name: "cron-on-server-2",
TaskType: model.CronTypeCronTask,
Command: "echo s2",
Servers: []uint64{2},
Cover: model.CronCoverIgnoreAll,
}
require.NoError(t, db.Create(cr7).Error)
require.NoError(t, db.Create(cr8).Error)
singleton.CronShared.Update(cr7)
singleton.CronShared.Update(cr8)
t.Cleanup(func() {
singleton.CronShared.Close()
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Cache = originalCache
singleton.Loc = originalLoc
singleton.Localizer = originalLocalizer
singleton.CronShared = originalCron
singleton.ServerShared = originalServer
singleton.UserLock.Lock()
singleton.UserInfoMap = originalUserInfo
singleton.UserLock.Unlock()
})
return cr7.ID, cr8.ID
}
func newCronPATRouter(tok *model.APIToken) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
setAuthUser(c, 100, model.RoleMember)
if tok != nil {
c.Set(model.CtxKeyAPIToken, tok)
c.Set(apiTokenCtxKey, tok)
}
c.Next()
})
r.POST("/api/v1/cron/:id/manual", commonHandler(manualTriggerCron))
r.GET("/api/v1/cron", listHandler(listCron))
return r
}
func TestCronManualTrigger_DeniesServerOutsidePATWhitelist(t *testing.T) {
_, cron8 := setupCronPATWhitelistFixture(t)
tok := &model.APIToken{ID: 17, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := newCronPATRouter(tok)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost,
"/api/v1/cron/"+strconv.FormatUint(cron8, 10)+"/manual", nil)
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.False(t, success,
"PAT whitelist [1] must not allow triggering a cron bound to server 2")
assert.Contains(t, errMsg, "permission denied")
}
func TestCronManualTrigger_AllowsServerInsidePATWhitelist(t *testing.T) {
cron7, _ := setupCronPATWhitelistFixture(t)
tok := &model.APIToken{ID: 17, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := newCronPATRouter(tok)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost,
"/api/v1/cron/"+strconv.FormatUint(cron7, 10)+"/manual", nil)
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.True(t, success,
"PAT whitelist [1] must still allow triggering a cron bound to server 1: error=%s", errMsg)
}
func TestListCron_HidesRowsForServersOutsidePATWhitelist(t *testing.T) {
cron7, cron8 := setupCronPATWhitelistFixture(t)
tok := &model.APIToken{ID: 17, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := newCronPATRouter(tok)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/cron", nil)
r.ServeHTTP(w, req)
var resp struct {
Success bool `json:"success"`
Error string `json:"error"`
Data []*model.Cron `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp.Success, resp.Error)
seen := map[uint64]bool{}
for _, c := range resp.Data {
seen[c.ID] = true
}
assert.True(t, seen[cron7], "cron bound to whitelisted server 1 must remain visible")
assert.False(t, seen[cron8],
"cron bound to non-whitelisted server 2 must be hidden from PAT view (rows=%+v)", resp.Data)
}
@@ -0,0 +1,349 @@
package controller
// Regression tests for the implicit-cover PAT bypass classes.
//
// Background: ServerShared.CheckPermission iterates an idList and returns true
// for an empty list — it can only veto explicit IDs. createCron / createService
// both pipe cf.Servers (cron) and ss.SkipServers (service) through that helper.
// But under cover=CronCoverAll the cron's Servers slice is a *deny list* (and
// empty → fan out to every server owned by the user); under cover=ServiceCoverAll
// the service's SkipServers map is the equivalent deny set. A PAT scoped to
// server_ids=[1] can therefore craft a "cover all, deny none" config and force
// dashboard to dispatch cron commands / service probes to servers outside the
// PAT whitelist.
//
// These tests are deliberately end-to-end through commonHandler so a future
// refactor that moves the guard to a different layer still has to satisfy the
// "PAT can't escape its whitelist via cover semantics" invariant.
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
// setupCoverPATFixture builds a member-owned, two-server universe.
// alice (uid=100) owns server 1 and server 2. The caller PAT below will be
// scoped to server_ids=[1] only, so cover-all configs that fan out to
// server 2 must be rejected at the create/update boundary.
func setupCoverPATFixture(t *testing.T) {
t.Helper()
originalDB := singleton.DB
originalCache := singleton.Cache
originalLoc := singleton.Loc
originalLocalizer := singleton.Localizer
originalCron := singleton.CronShared
originalServer := singleton.ServerShared
originalUserInfo := singleton.UserInfoMap
originalNotification := singleton.NotificationShared
originalSentinel := singleton.ServiceSentinelShared
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Cron{}, &model.Server{}, &model.User{}, &model.Service{}, &model.NotificationGroup{}, &model.ServiceHistory{}))
singleton.DB = db
singleton.Loc = time.UTC
singleton.Cache = cache.New(time.Minute, time.Minute)
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
singleton.NotificationShared = singleton.NewEmptyNotificationClassForTest()
sc := singleton.NewEmptyServerClassForTest()
singleton.ServerShared = sc
singleton.CronShared = singleton.NewCronClass()
sentinel, err := singleton.NewServiceSentinel(make(chan *model.Service, 4))
require.NoError(t, err)
singleton.ServiceSentinelShared = sentinel
for _, id := range []uint64{1, 2} {
s := &model.Server{}
s.ID = id
s.SetUserID(100)
sc.InsertForTest(s)
}
singleton.ServerShared = sc
singleton.UserLock.Lock()
singleton.UserInfoMap = map[uint64]model.UserInfo{100: {Role: model.RoleMember}}
singleton.UserLock.Unlock()
t.Cleanup(func() {
// Background components must be joined before restoring process globals.
sentinel.Close()
singleton.CronShared.Close()
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Cache = originalCache
singleton.Loc = originalLoc
singleton.Localizer = originalLocalizer
singleton.CronShared = originalCron
singleton.ServerShared = originalServer
singleton.NotificationShared = originalNotification
singleton.ServiceSentinelShared = originalSentinel
singleton.UserLock.Lock()
singleton.UserInfoMap = originalUserInfo
singleton.UserLock.Unlock()
})
}
func coverPATRouter(t *testing.T, tok *model.APIToken, handler func(*gin.Context)) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
setAuthUser(c, 100, model.RoleMember)
if tok != nil {
c.Set(model.CtxKeyAPIToken, tok)
c.Set(apiTokenCtxKey, tok)
}
c.Next()
})
r.POST("/api/v1/cron", handler)
r.POST("/api/v1/service", handler)
return r
}
func TestCreateCron_RejectsCoverAllForServerLimitedPAT(t *testing.T) {
setupCoverPATFixture(t)
tok := &model.APIToken{ID: 17, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := coverPATRouter(t, tok, commonHandler(createCron))
body, _ := json.Marshal(model.CronForm{
TaskType: model.CronTypeCronTask,
Name: "evil cover-all",
Scheduler: "@every 1m",
Command: "echo pwned",
Servers: nil,
Cover: model.CronCoverAll,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/cron", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.False(t, success,
"PAT scoped to server_ids=[1] must NOT be able to create a CronCoverAll cron with no Servers — that fans out to server 2 outside the whitelist")
assert.Contains(t, errMsg, "permission denied")
var rows []model.Cron
require.NoError(t, singleton.DB.Find(&rows).Error)
assert.Empty(t, rows, "no cron row must be persisted when the create call is rejected")
}
func TestCreateCron_RejectsCoverIgnoreAllWithEmptyServersForLimitedPAT(t *testing.T) {
// CoverIgnoreAll + empty Servers is "allow-list of zero" → effectively a
// no-op cron. We still reject it because it normalises away the
// whitelist hint a curious caller might attempt next ("just flip cover
// to All and we'll get fan-out"). Defence-in-depth: any cover-mode that
// implies dispatch beyond the literal Servers slice must require the
// PAT to cover at least one whitelisted server explicitly.
setupCoverPATFixture(t)
tok := &model.APIToken{ID: 18, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := coverPATRouter(t, tok, commonHandler(createCron))
body, _ := json.Marshal(model.CronForm{
TaskType: model.CronTypeCronTask,
Name: "ambiguous-cover",
Scheduler: "@every 1m",
Command: "echo",
Servers: nil,
Cover: model.CronCoverIgnoreAll,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/cron", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
// Empty Servers + IgnoreAll is the degenerate "matches nothing" case;
// it must succeed (it cannot escape) so legitimate API consumers
// who serialise a 0-server allow-list aren't blocked.
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.True(t, success, "CoverIgnoreAll with no Servers is a no-op; not a bypass: error=%s", errMsg)
}
func TestCreateService_AllowsCoverIgnoreAllEmptySkipForLimitedPAT(t *testing.T) {
// ServiceCoverIgnoreAll + empty SkipServers is the degenerate "matches
// nothing" case: DispatchTask iterates only entries marked true in
// SkipServers, so an empty map causes zero fan-out. Pin the no-op
// classification so a future refactor that broadens IgnoreAll's
// semantics has to update this test (and the dispatch-side guard) in
// lock-step with the writer-side guard.
setupCoverPATFixture(t)
tok := &model.APIToken{ID: 21, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := coverPATRouter(t, tok, commonHandler(createService))
body, _ := json.Marshal(model.ServiceForm{
Name: "no-op monitor",
Target: "example.invalid:80",
Type: model.TaskTypeTCPPing,
Cover: model.ServiceCoverIgnoreAll,
SkipServers: nil,
Duration: 30,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/service", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.True(t, success, "CoverIgnoreAll with no SkipServers is a no-op; not a bypass: error=%s", errMsg)
}
func TestCreateService_RejectsCoverAllForServerLimitedPAT(t *testing.T) {
setupCoverPATFixture(t)
tok := &model.APIToken{ID: 19, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := coverPATRouter(t, tok, commonHandler(createService))
body, _ := json.Marshal(model.ServiceForm{
Name: "evil cover-all monitor",
Target: "example.invalid:443",
Type: model.TaskTypeTCPPing,
Cover: model.ServiceCoverAll,
SkipServers: nil,
Duration: 30,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/service", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.False(t, success,
"PAT scoped to server_ids=[1] must NOT be able to create a ServiceCoverAll monitor with no SkipServers — DispatchTask fans out to server 2 outside the whitelist")
assert.Contains(t, errMsg, "permission denied")
var rows []model.Service
require.NoError(t, singleton.DB.Find(&rows).Error)
assert.Empty(t, rows, "no service row must be persisted when the create call is rejected")
}
// Threat: PAT server_ids=[1] + Cover=CronCoverAll + Servers=[1] (deny-list)
// passes the writer-side guard (len(Servers)>0), then CronTrigger iterates all
// owner servers, skips the whitelisted server 1, and dispatches to server 2 —
// outside the whitelist. CronTrigger has no PAT context, so the write-time
// guard is the only enforcement point.
func TestCreateCron_RejectsCoverAllWithDenyListCoveringOnlyWhitelistedServers(t *testing.T) {
setupCoverPATFixture(t)
tok := &model.APIToken{ID: 31, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := coverPATRouter(t, tok, commonHandler(createCron))
body, _ := json.Marshal(model.CronForm{
TaskType: model.CronTypeCronTask,
Name: "cover-all deny-only-whitelisted",
Scheduler: "@every 1m",
Command: "echo pwned-via-server-2",
Servers: []uint64{1},
Cover: model.CronCoverAll,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/cron", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.False(t, success,
"PAT [1] must NOT create a CronCoverAll whose deny-list only contains whitelisted servers; CronTrigger would fan out to server 2")
assert.Contains(t, errMsg, "permission denied")
var rows []model.Cron
require.NoError(t, singleton.DB.Find(&rows).Error)
assert.Empty(t, rows, "no cron row must be persisted when the create call is rejected")
}
// Positive case: a server-limited PAT IS allowed to create CronCoverAll when
// the deny-list already covers every owner-visible server outside its
// whitelist. Pinning this prevents future "just block all CoverAll for PATs"
// over-corrections that would break a legitimate "schedule on whitelisted
// servers only, via deny-list" workflow.
func TestCreateCron_AllowsCoverAllWhenDenyListCoversAllNonWhitelistedServers(t *testing.T) {
setupCoverPATFixture(t)
tok := &model.APIToken{ID: 41, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := coverPATRouter(t, tok, commonHandler(createCron))
body, _ := json.Marshal(model.CronForm{
TaskType: model.CronTypeCronTask,
Name: "legit cover-all",
Scheduler: "@every 1m",
Command: "echo s1-only",
Servers: []uint64{2},
Cover: model.CronCoverAll,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/cron", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.True(t, success,
"CronCoverAll with deny-list covering every non-whitelisted server must succeed for a server-limited PAT: error=%s", errMsg)
}
// Service-monitor analogue of the cron deny-list bypass: ServiceCoverAll +
// SkipServers={1:true} passes the writer-side guard (skipCount>0), then
// DispatchTask probes server 2. Same write-time enforcement requirement.
func TestCreateService_RejectsCoverAllWithSkipListCoveringOnlyWhitelistedServers(t *testing.T) {
setupCoverPATFixture(t)
tok := &model.APIToken{ID: 32, UserID: 100}
tok.SetServerIDs([]uint64{1})
r := coverPATRouter(t, tok, commonHandler(createService))
body, _ := json.Marshal(model.ServiceForm{
Name: "cover-all skip-only-whitelisted monitor",
Target: "example.invalid:8443",
Type: model.TaskTypeTCPPing,
Cover: model.ServiceCoverAll,
SkipServers: map[uint64]bool{1: true},
Duration: 30,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/service", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
assert.False(t, success,
"PAT [1] must NOT create a ServiceCoverAll whose SkipServers only marks whitelisted servers; DispatchTask would probe server 2")
assert.Contains(t, errMsg, "permission denied")
var rows []model.Service
require.NoError(t, singleton.DB.Find(&rows).Error)
assert.Empty(t, rows, "no service row must be persisted when the create call is rejected")
}
@@ -0,0 +1,103 @@
package controller
import (
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
func setupCronUpdateOwnerUIDFixture(t *testing.T) {
t.Helper()
originalCache := singleton.Cache
originalLoc := singleton.Loc
originalLocalizer := singleton.Localizer
originalServer := singleton.ServerShared
singleton.Loc = time.UTC
singleton.Cache = cache.New(time.Minute, time.Minute)
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
sc := singleton.NewEmptyServerClassForTest()
for _, id := range []uint64{1, 2} {
s := &model.Server{}
s.ID = id
s.SetUserID(100)
sc.InsertForTest(s)
}
adminServer := &model.Server{}
adminServer.ID = 5
adminServer.SetUserID(200)
sc.InsertForTest(adminServer)
singleton.ServerShared = sc
t.Cleanup(func() {
singleton.Cache = originalCache
singleton.Loc = originalLoc
singleton.Localizer = originalLocalizer
singleton.ServerShared = originalServer
})
}
func newCtxAsAdminWithLimitedPAT(t *testing.T, callerUID uint64, whitelist []uint64) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set(model.CtxKeyAuthorizedUser, &model.User{
Common: model.Common{ID: callerUID},
Role: model.RoleAdmin,
})
tok := &model.APIToken{ID: 33, UserID: callerUID}
tok.SetServerIDs(whitelist)
c.Set(apiTokenCtxKey, tok)
c.Set(model.CtxKeyAPIToken, tok)
return c
}
// Threat: updateCron currently calls
//
// rejectImplicitCoverForLimitedPAT(c, cf.Cover, cf.Servers)
//
// which internally resolves the owner UID via getUid(c) (caller id). When an
// admin uses a server-limited PAT to flip a *foreign* cron to CoverAll with
// an under-specified deny-list, the helper validates the deny-list against
// the admin's own servers, not the cron owner's. The admin's only owned
// server is 5 and it's already in the whitelist, so the guard returns nil
// even though CronTrigger will fan out to the cron owner's servers 1 and 2
// — both outside the PAT whitelist. The correct owner is the existing
// cron.UserID, not the caller. This test calls the helper directly with the
// cron owner uid and pins the safe behaviour.
func TestRejectImplicitCoverForLimitedPAT_RejectsCallerWhenCronOwnerHasUncoveredServers(t *testing.T) {
setupCronUpdateOwnerUIDFixture(t)
c := newCtxAsAdminWithLimitedPAT(t, 200, []uint64{5})
const cronOwnerUID = uint64(100)
err := rejectImplicitCoverForLimitedPATWithOwner(c, model.CronCoverAll, nil, cronOwnerUID)
require.Error(t, err,
"limited PAT must NOT pass cover-all check when the cron owner has servers outside the PAT whitelist; caller uid must not be used as owner")
assert.Contains(t, err.Error(), "permission denied")
}
// Pins the safe path: same helper, but caller uid happens to equal the cron
// owner and the deny-list covers every owner-visible server outside the
// whitelist. Prevents regressing the helper into a blanket "always deny
// limited PAT" form.
func TestRejectImplicitCoverForLimitedPAT_AllowsCallerWhenDenyListCoversEveryOwnerServerOutsideWhitelist(t *testing.T) {
setupCronUpdateOwnerUIDFixture(t)
c := newCtxAsAdminWithLimitedPAT(t, 100, []uint64{1})
err := rejectImplicitCoverForLimitedPATWithOwner(c, model.CronCoverAll, []uint64{2}, 100)
require.NoError(t, err,
"deny-list [2] covers every server uid 100 owns outside the PAT whitelist [1]; must pass")
}
+149
View File
@@ -0,0 +1,149 @@
package controller
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// issueCSRFToken mints a signed double-submit token (nonce.HMAC-SHA256 keyed
// by the JWT secret). Signing defeats sibling-subdomain cookie tossing: a
// naive double-submit trusts any header==cookie pair, but an injected cookie
// carries no valid HMAC and fails validateCSRFToken. Returns "" pre-init
// (no secret); callers treat that as "no cookie minted".
func issueCSRFToken() string {
secret := csrfSigningSecret()
if secret == "" {
return ""
}
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
nonce := hex.EncodeToString(b[:])
return nonce + "." + csrfSign(nonce, secret)
}
func csrfSigningSecret() string {
if singleton.Conf == nil {
return ""
}
return singleton.Conf.JWTSecretKey
}
func csrfSign(nonce, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(nonce))
return hex.EncodeToString(mac.Sum(nil))
}
// validateCSRFToken reports whether value is a well-formed nonce.signature
// pair whose signature verifies under the current server secret. Constant
// -time comparison guards against signature-probing side channels.
func validateCSRFToken(value string) bool {
secret := csrfSigningSecret()
if secret == "" || value == "" {
return false
}
idx := strings.LastIndex(value, ".")
if idx <= 0 || idx == len(value)-1 {
return false
}
nonce, sig := value[:idx], value[idx+1:]
return hmac.Equal([]byte(sig), []byte(csrfSign(nonce, secret)))
}
// setCSRFCookie issues a fresh signed CSRF token cookie. Called by login +
// refresh handlers so the frontend always has a paired value to mirror back
// into the X-CSRF-Token header. The cookie is intentionally HttpOnly=false —
// SPA JS must be able to read it. SameSite=Strict here (not Lax) because
// the cookie's sole purpose is the same-origin double-submit check and we
// don't want it leaking on cross-site GET navigation either.
func setCSRFCookie(c *gin.Context) {
token := issueCSRFToken()
if token == "" {
return
}
// Secure is set only when the request arrives over HTTPS, mirroring
// writeOauth2StateCookie. On plain HTTP (e.g. intranet deployments) a
// Secure cookie would be dropped by the browser, breaking the
// double-submit pair, so we must not force it unconditionally.
secure := c.Request.URL.Scheme == "https" || c.Request.TLS != nil
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(csrfCookieName, token, 0, "/", "", secure, false)
}
const (
csrfCookieName = "nz-csrf"
csrfHeaderName = "X-CSRF-Token"
)
// csrfMiddleware enforces a double-submit-cookie CSRF gate on unsafe
// HTTP methods for cookie-authenticated requests.
//
// Why: SameSite=Lax on the nz-jwt cookie blocks the simplest cross-site
// form POST, but it does not stop same-site XSS-pivot CSRF, header method
// override, redirect-leaking auth helpers, or carefully chained sub-domain
// attacks. The double-submit pattern (server sets a JS-readable nz-csrf
// cookie, client mirrors the value into X-CSRF-Token) closes the gap
// without coupling auth state to a server-side session.
//
// Bypass conditions:
// - Safe methods (GET/HEAD/OPTIONS): no state mutation, no CSRF risk.
// - Bearer-token PAT requests (`Authorization: Bearer nzp_*`): stateless,
// no ambient cookie, so a CSRF attack cannot induce them.
//
// Reject conditions:
// - Missing or empty X-CSRF-Token header.
// - Missing or empty nz-csrf cookie.
// - Header value != cookie value.
// - Cookie value not signed by the server (validateCSRFToken fails).
//
// The middleware DOES NOT set the csrf cookie on its own — that is the
// JWT login / refresh handler's job, since those are the only places that
// know when to mint a fresh value. The pair just has to exist by the time
// any unsafe call reaches here.
func csrfMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
// Self-heal sessions that predate the CSRF cookie (or whose
// nz-csrf expired): seed a fresh value on a safe method so the
// double-submit pair exists before the next unsafe call. Safe
// methods mutate nothing, so minting here carries no CSRF risk.
if cookie, err := c.Cookie(csrfCookieName); err != nil || cookie == "" {
setCSRFCookie(c)
}
c.Next()
return
}
// A PAT request carries no ambient cookie, so CSRF cannot induce it.
// The exemption must check the authenticated PAT identity resolved by
// apiTokenAuthMiddleware, not a forgeable Authorization header value.
if APITokenFromContext(c) != nil {
c.Next()
return
}
header := c.GetHeader(csrfHeaderName)
cookie, err := c.Cookie(csrfCookieName)
// Both halves must be present, mirror each other, AND carry a valid
// server signature. The signature check is what stops a cookie-tossed
// pair from a sibling subdomain.
if err != nil || cookie == "" || header == "" || header != cookie || !validateCSRFToken(cookie) {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: missing or invalid CSRF token",
})
return
}
c.Next()
}
}
@@ -0,0 +1,84 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func withCSRFSecret(t *testing.T, secret string) {
t.Helper()
prev := singleton.Conf
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{}}
singleton.Conf.JWTSecretKey = secret
t.Cleanup(func() { singleton.Conf = prev })
}
// Sibling-subdomain cookie tossing: an attacker who can set nz-csrf for the
// parent domain injects an attacker-chosen value and mirrors it into the
// header. A naive double-submit accepts header==cookie. The signed
// double-submit must reject it because the injected value carries no valid
// server HMAC.
func TestCSRFMiddleware_RejectsUnsignedInjectedPair(t *testing.T) {
withCSRFSecret(t, "test-jwt-secret")
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-jwt", Value: "session"})
c.Request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "attacker-chosen"})
c.Request.Header.Set(csrfHeaderName, "attacker-chosen")
mw(c)
if !c.IsAborted() || w.Code != http.StatusForbidden {
t.Fatalf("an unsigned (cookie-tossed) csrf pair must be rejected, got aborted=%v code=%d", c.IsAborted(), w.Code)
}
}
// A token minted by the server (issueCSRFToken) must pass when mirrored
// correctly into the header.
func TestCSRFMiddleware_AcceptsServerSignedPair(t *testing.T) {
withCSRFSecret(t, "test-jwt-secret")
token := issueCSRFToken()
if token == "" {
t.Fatal("issueCSRFToken must produce a value")
}
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-jwt", Value: "session"})
c.Request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token})
c.Request.Header.Set(csrfHeaderName, token)
mw(c)
if c.IsAborted() {
t.Fatal("a correctly mirrored server-signed csrf pair must pass")
}
}
// Even with header==cookie, a value whose signature does not verify under the
// server secret must be rejected (forged signature segment).
func TestCSRFMiddleware_RejectsForgedSignature(t *testing.T) {
withCSRFSecret(t, "test-jwt-secret")
token := issueCSRFToken()
forged := token[:len(token)-1]
if token[len(token)-1] == 'a' {
forged += "b"
} else {
forged += "a"
}
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: forged})
c.Request.Header.Set(csrfHeaderName, forged)
mw(c)
if !c.IsAborted() || w.Code != http.StatusForbidden {
t.Fatalf("a forged-signature csrf pair must be rejected, got aborted=%v code=%d", c.IsAborted(), w.Code)
}
}
+162
View File
@@ -0,0 +1,162 @@
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
)
// H6 regression: cookie-JWT unsafe-method routes need a real CSRF gate.
// SameSite=Lax blocks the obvious cross-site form-POST but does not stop
// same-site siblings, sub-domain XSS pivots, header method override quirks,
// or the various legacy edge cases. The middleware below is the double
// -submit cookie pattern: require X-CSRF-Token whose value matches the
// `nz-csrf` cookie. PAT bearer requests bypass the gate (they don't carry
// the cookie at all and are already authenticated stateless).
func TestCSRFMiddleware_AllowsSafeMethodsWithoutToken(t *testing.T) {
mw := csrfMiddleware()
for _, m := range []string{"GET", "HEAD", "OPTIONS"} {
t.Run(m, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(m, "/api/v1/profile", nil)
mw(c)
if c.IsAborted() {
t.Fatalf("%s must pass without csrf token", m)
}
})
}
}
// Self-heal: a session created before the CSRF cookie existed (or one whose
// nz-csrf expired) only carries nz-jwt. Without seeding a fresh nz-csrf on a
// safe GET, every subsequent unsafe call — including the auto refresh-token
// POST — would 403 forever and force a manual re-login. GET carries no CSRF
// risk, so the middleware mints the cookie when it is absent.
func TestCSRFMiddleware_SeedsCookieOnSafeMethodWhenMissing(t *testing.T) {
withCSRFSecret(t, "test-jwt-secret")
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-jwt", Value: "session"})
mw(c)
if c.IsAborted() {
t.Fatal("safe GET must never abort")
}
var seeded bool
for _, sc := range w.Result().Cookies() {
if sc.Name == csrfCookieName && sc.Value != "" {
seeded = true
}
}
if !seeded {
t.Fatal("missing nz-csrf must be seeded on a safe GET so the SPA can self-heal")
}
}
func TestCSRFMiddleware_DoesNotReseedWhenCookiePresent(t *testing.T) {
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "existing"})
mw(c)
for _, sc := range w.Result().Cookies() {
if sc.Name == csrfCookieName {
t.Fatal("existing nz-csrf must not be rotated on every GET")
}
}
}
func TestCSRFMiddleware_BlocksUnsafeMethodWithoutToken(t *testing.T) {
mw := csrfMiddleware()
for _, m := range []string{"POST", "PATCH", "PUT", "DELETE"} {
t.Run(m, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(m, "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-jwt", Value: "anything"})
mw(c)
if !c.IsAborted() || w.Code != http.StatusForbidden {
t.Fatalf("%s without csrf token must abort 403, got aborted=%v code=%d", m, c.IsAborted(), w.Code)
}
})
}
}
func TestCSRFMiddleware_AcceptsMatchingHeaderAndCookie(t *testing.T) {
withCSRFSecret(t, "test-jwt-secret")
token := issueCSRFToken()
mw := csrfMiddleware()
for _, m := range []string{"POST", "PATCH", "PUT", "DELETE"} {
t.Run(m, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(m, "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-jwt", Value: "anything"})
c.Request.AddCookie(&http.Cookie{Name: "nz-csrf", Value: token})
c.Request.Header.Set("X-CSRF-Token", token)
mw(c)
if c.IsAborted() {
t.Fatalf("%s with matching signed csrf header+cookie must pass", m)
}
})
}
}
func TestCSRFMiddleware_RejectsMismatchedHeader(t *testing.T) {
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-csrf", Value: "value-a"})
c.Request.Header.Set("X-CSRF-Token", "value-b")
mw(c)
if !c.IsAborted() || w.Code != http.StatusForbidden {
t.Fatalf("mismatched csrf token must abort 403, got aborted=%v code=%d", c.IsAborted(), w.Code)
}
}
func TestCSRFMiddleware_AuthenticatedPATBypassesCheck(t *testing.T) {
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/mcp", strings.NewReader("{}"))
c.Set(apiTokenCtxKey, &model.APIToken{ID: 1})
mw(c)
if c.IsAborted() {
t.Fatal("authenticated PAT must bypass CSRF — stateless auth")
}
}
func TestCSRFMiddleware_ForgedBearerHeaderDoesNotBypass(t *testing.T) {
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-jwt", Value: "session"})
c.Request.Header.Set("Authorization", "Bearer "+model.APITokenPrefix+"never-authenticated")
mw(c)
if !c.IsAborted() || w.Code != http.StatusForbidden {
t.Fatalf("a Bearer nzp_* header that never authenticated must not skip CSRF, got aborted=%v code=%d", c.IsAborted(), w.Code)
}
}
func TestCSRFMiddleware_EmptyTokenRejected(t *testing.T) {
mw := csrfMiddleware()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/profile", nil)
c.Request.AddCookie(&http.Cookie{Name: "nz-csrf", Value: ""})
c.Request.Header.Set("X-CSRF-Token", "")
mw(c)
if !c.IsAborted() {
t.Fatal("empty csrf token must not satisfy the check (defeats the gate entirely)")
}
}
@@ -0,0 +1,162 @@
package controller
import (
"context"
"log"
"strings"
jwt "github.com/golang-jwt/jwt/v4"
"gorm.io/gorm"
"gorm.io/gorm/logger"
ginjwt "github.com/appleboy/gin-jwt/v2"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// dashboardCredentialJWTParser verifies that a JWT was signed by this
// dashboard. It is derived from the auth middleware created in routers() and
// exists only for dashboard-credential classification; it must never
// authenticate dashboard requests on its own.
var dashboardCredentialJWTParser *ginjwt.GinJWTMiddleware
// newDashboardCredentialJWTParser derives a signature-only parser from the
// dashboard auth middleware. It keeps the exact key value and the pinned HS256
// signing algorithm of initParams() — there is no second read of
// singleton.Conf.JWTSecretKey — and disables claims validation so expiry does
// not change the verdict: an expired token is still proof the dashboard issued
// it, and keeping expired dashboard tokens out of the NAT tunnel prevents a
// careless backend from treating "any JWT" as its own credential. The live
// auth middleware keeps full claims validation; dashboard authentication is
// not relaxed by this parser in any way.
func newDashboardCredentialJWTParser(authMiddleware *ginjwt.GinJWTMiddleware) *ginjwt.GinJWTMiddleware {
parser := *authMiddleware
parser.ParseOptions = []jwt.ParserOption{jwt.WithoutClaimsValidation()}
return &parser
}
// IsDashboardCredential reports whether an Authorization header value was
// issued by this dashboard: a panel-signed JWT or a panel API token (PAT).
//
// NAT ingress uses this to enforce the tunnel credential invariant: values
// classified as dashboard-issued are stripped before the request is tunneled
// to the agent, while foreign credentials are forwarded untouched because the
// NAT backend may authenticate with credentials of its own.
//
// Decision policy mirrors the dashboard's own auth middlewares: only the
// case-sensitive "Bearer " scheme is considered; the value after the scheme is
// classified by IsDashboardCredentialValue, which is also the entry point for
// the panel's scheme-less TokenLookup channels. Only a deterministic proof
// that the value was not issued by this dashboard (scheme mismatch, unknown
// PAT hash, failed signature verification) returns false.
func IsDashboardCredential(authz string) bool {
raw := strings.TrimSpace(authz)
if !strings.HasPrefix(raw, "Bearer ") {
return false
}
return IsDashboardCredentialValue(strings.TrimPrefix(raw, "Bearer "))
}
// IsDashboardCredentialValue reports whether a raw credential value — the part
// after the "Bearer " scheme, exactly as the panel's other TokenLookup
// channels carry it (the ?token= query parameter and the nz-jwt cookie, see
// initParams) — was issued by this dashboard: a panel-signed JWT or a panel
// API token (PAT).
//
// Failure policy is identical to IsDashboardCredential: whenever the
// classification cannot be completed (parser or config not ready, database
// failure) the value is conservatively reported as a dashboard credential so
// the caller strips it; only a deterministic proof that the value was not
// issued by this dashboard (unknown PAT hash, failed signature verification)
// returns false.
func IsDashboardCredentialValue(value string) bool {
classified := ClassifyDashboardCredentialValues(context.Background(), []string{value})
if len(classified) != 1 {
return true
}
return classified[0]
}
// ClassifyDashboardCredentialValues classifies a bounded request batch. JWTs
// are checked independently, while all PAT-shaped values are hashed and
// resolved with one silent IN query. This prevents attacker-controlled NAT
// query parameters or cookies from amplifying one HTTP request into thousands
// of SQLite queries and record-not-found log entries.
func ClassifyDashboardCredentialValues(ctx context.Context, values []string) []bool {
if ctx == nil {
ctx = context.Background()
}
results := make([]bool, len(values))
patIndexes := make(map[string][]int)
for i, value := range values {
plaintext := strings.TrimSpace(value)
if strings.HasPrefix(plaintext, model.APITokenPrefix) {
hash := model.HashAPIToken(plaintext)
patIndexes[hash] = append(patIndexes[hash], i)
continue
}
results[i] = isDashboardSignedJWT(plaintext)
}
if len(patIndexes) == 0 {
return results
}
if singleton.DB == nil {
logDashboardCredentialUnclassifiable("api token lookup unavailable")
for _, indexes := range patIndexes {
for _, i := range indexes {
results[i] = true
}
}
return results
}
hashes := make([]string, 0, len(patIndexes))
for hash := range patIndexes {
hashes = append(hashes, hash)
}
type tokenHashRow struct {
TokenHash string
}
var rows []tokenHashRow
err := singleton.DB.WithContext(ctx).
Session(&gorm.Session{Logger: logger.Discard}).
Model(&model.APIToken{}).
Select("token_hash").
Where("token_hash IN ?", hashes).
Find(&rows).Error
if err != nil {
logDashboardCredentialUnclassifiable("api token lookup failed")
for _, indexes := range patIndexes {
for _, i := range indexes {
results[i] = true
}
}
return results
}
for _, row := range rows {
for _, i := range patIndexes[row.TokenHash] {
results[i] = true
}
}
return results
}
// isDashboardSignedJWT verifies the token signature with the dashboard's
// pinned algorithm and key. Claims validation is disabled on purpose (see
// newDashboardCredentialJWTParser): expiry does not change who issued the
// token.
func isDashboardSignedJWT(token string) bool {
if dashboardCredentialJWTParser == nil {
// routers() has not run yet (startup incomplete): fail closed.
logDashboardCredentialUnclassifiable("dashboard credential parser unavailable")
return true
}
_, err := dashboardCredentialJWTParser.ParseTokenString(token)
return err == nil
}
// logDashboardCredentialUnclassifiable records a classification failure. The
// credential value itself is never included in the message.
func logDashboardCredentialUnclassifiable(reason string) {
log.Printf("NEZHA>> NAT ingress: credential value could not be classified (%s); stripping it as a precaution", reason)
}
@@ -0,0 +1,250 @@
package controller
import (
"bufio"
"bytes"
"context"
"io"
"net"
"net/http"
"net/url"
"sync"
"testing"
"time"
"github.com/goccy/go-json"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata"
"github.com/nezhahq/nezha/cmd/dashboard/rpc"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/proto"
rpcService "github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
// setupNATFlowTest extends setupDashboardCredentialTest with the minimal
// ServeNAT environment: a registered server with a task stream and a fresh
// handler singleton, mirroring the rpc package's serveNAT fixture.
func setupNATFlowTest(t *testing.T) (*rpcService.NezhaHandler, *model.Server, *natFlowTaskStream) {
t.Helper()
setupDashboardCredentialTest(t)
require.NoError(t, singleton.DB.AutoMigrate(&model.Server{}))
originalServerShared, originalHandler := singleton.ServerShared, rpcService.NezhaHandlerSingleton
t.Cleanup(func() {
singleton.ServerShared, rpcService.NezhaHandlerSingleton = originalServerShared, originalHandler
})
serverRow := &model.Server{Common: model.Common{ID: 7}, UUID: "nat-credential-flow-test", Name: "nat-credential-flow-test"}
require.NoError(t, singleton.DB.Create(serverRow).Error)
singleton.ServerShared = singleton.NewServerClass()
handler := rpcService.NewNezhaHandler()
rpcService.NezhaHandlerSingleton = handler
server, ok := singleton.ServerShared.Get(serverRow.ID)
require.True(t, ok)
taskStream := &natFlowTaskStream{}
server.SetTaskStream(taskStream)
// Wire the gate exactly as cmd/dashboard/main.go does, with the real
// classifiers built by setupDashboardCredentialTest.
rpc.SetNATDashboardCredentialGate(ClassifyDashboardCredentialValues)
t.Cleanup(func() { rpc.SetNATDashboardCredentialGate(nil) })
return handler, server, taskStream
}
// TestServeNATWithRealClassifierStripsDashboardCredentialsFromAllChannels pins
// the main() wiring contract end to end: with the real classifiers wired as
// main() does, ServeNAT forwards a request whose dashboard-issued credentials
// — Authorization header, ?token= query parameter, nz-jwt cookie — are all
// stripped, while foreign values and untouched query/cookie data survive
// byte-for-byte.
func TestServeNATWithRealClassifierStripsDashboardCredentialsFromAllChannels(t *testing.T) {
handler, server, taskStream := setupNATFlowTest(t)
dashboardJWT := mintDashboardCredentialJWT(t, time.Hour)
foreignJWT := mintForeignJWT(t)
// No api_tokens row hashes to this PAT, so the classifier proves it was
// not issued by this dashboard and it must survive the tunnel.
foreignPAT := model.APITokenPrefix + "foreign-issued-pat"
dashboardPAT := model.APITokenPrefix + "dashboard-issued-pat"
require.NoError(t, singleton.DB.Create(&model.APIToken{
UserID: 1,
Name: "nat-flow",
TokenHash: model.HashAPIToken(dashboardPAT),
}).Error)
connection := &natFlowConn{closed: make(chan struct{})}
agent := &natFlowAgent{writeDone: make(chan struct{})}
writer := &natFlowWriter{conn: connection}
request := &http.Request{
Method: http.MethodPost,
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + dashboardJWT + "&token=" + url.QueryEscape(foreignJWT) +
"&token=" + foreignPAT + "&token=" + dashboardPAT},
Header: make(http.Header),
Body: http.NoBody,
}
request.Header.Set("Authorization", "Bearer "+dashboardJWT)
request.Header.Set("Cookie", "sid=abc; nz-jwt="+dashboardJWT+"; nz-jwt="+foreignJWT+"; nz-jwt="+foreignPAT)
request.Header.Set("X-Ordinary-Nat", "ordinary")
taskStream.onSend = func(task *proto.Task) error {
var nat model.TaskNAT
if err := json.Unmarshal([]byte(task.Data), &nat); err != nil {
return err
}
return handler.AgentConnected(nat.StreamID, agent)
}
done := make(chan struct{})
go func() {
rpc.ServeNAT(writer, request, &model.NAT{ServerID: server.ID, Host: "target.example"})
close(done)
}()
select {
case <-agent.writeDone:
case <-time.After(time.Second):
t.Fatal("agent did not receive the transferred NAT request")
}
require.NoError(t, connection.Close())
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("ServeNAT did not finish")
}
forwarded := string(agent.writtenBytes())
// Dashboard credentials are gone from every channel.
require.NotContains(t, forwarded, "Authorization:")
require.NotContains(t, forwarded, dashboardJWT)
require.NotContains(t, forwarded, dashboardPAT)
// Foreign credentials and untouched data survive byte-for-byte.
require.Contains(t, forwarded, "POST /nat?keep=1&token="+url.QueryEscape(foreignJWT)+"&token="+foreignPAT+" HTTP/1.1")
require.Contains(t, forwarded, "Cookie: sid=abc; nz-jwt="+foreignJWT+"; nz-jwt="+foreignPAT)
require.Contains(t, forwarded, "X-Ordinary-Nat: ordinary")
}
// TestServeNATWithRealClassifierKeepsForeignCredentials pins that the real
// classifier deterministically proves foreign values were not issued by this
// dashboard and leaves every channel untouched.
func TestServeNATWithRealClassifierKeepsForeignCredentials(t *testing.T) {
handler, server, taskStream := setupNATFlowTest(t)
foreignJWT := mintForeignJWT(t)
connection := &natFlowConn{closed: make(chan struct{})}
agent := &natFlowAgent{writeDone: make(chan struct{})}
writer := &natFlowWriter{conn: connection}
request := &http.Request{
Method: http.MethodPost,
URL: &url.URL{Path: "/nat", RawQuery: "token=" + foreignJWT},
Header: make(http.Header),
Body: http.NoBody,
}
request.Header.Set("Authorization", "Bearer "+foreignJWT)
request.Header.Set("Cookie", "nz-jwt="+foreignJWT)
taskStream.onSend = func(task *proto.Task) error {
var nat model.TaskNAT
if err := json.Unmarshal([]byte(task.Data), &nat); err != nil {
return err
}
return handler.AgentConnected(nat.StreamID, agent)
}
done := make(chan struct{})
go func() {
rpc.ServeNAT(writer, request, &model.NAT{ServerID: server.ID, Host: "target.example"})
close(done)
}()
select {
case <-agent.writeDone:
case <-time.After(time.Second):
t.Fatal("agent did not receive the transferred NAT request")
}
require.NoError(t, connection.Close())
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("ServeNAT did not finish")
}
forwarded := string(agent.writtenBytes())
require.Contains(t, forwarded, "POST /nat?token="+foreignJWT+" HTTP/1.1")
require.Contains(t, forwarded, "Authorization: Bearer "+foreignJWT)
require.Contains(t, forwarded, "Cookie: nz-jwt="+foreignJWT)
}
type natFlowTaskStream struct {
onSend func(*proto.Task) error
}
func (stream *natFlowTaskStream) Send(task *proto.Task) error {
if stream.onSend != nil {
return stream.onSend(task)
}
return nil
}
func (*natFlowTaskStream) Recv() (*proto.TaskResult, error) { return nil, io.EOF }
func (*natFlowTaskStream) SetHeader(metadata.MD) error { return nil }
func (*natFlowTaskStream) SendHeader(metadata.MD) error { return nil }
func (*natFlowTaskStream) SetTrailer(metadata.MD) {}
func (*natFlowTaskStream) Context() context.Context { return context.Background() }
func (*natFlowTaskStream) SendMsg(any) error { return nil }
func (*natFlowTaskStream) RecvMsg(any) error { return io.EOF }
type natFlowWriter struct{ conn *natFlowConn }
func (writer *natFlowWriter) Header() http.Header { return http.Header{} }
func (writer *natFlowWriter) Write(data []byte) (int, error) { return len(data), nil }
func (writer *natFlowWriter) WriteHeader(int) {}
func (writer *natFlowWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
go func() { _, _ = writer.conn.Read(make([]byte, 1)) }()
return writer.conn, bufio.NewReadWriter(bufio.NewReader(writer.conn), bufio.NewWriter(writer.conn)), nil
}
type natFlowConn struct {
closed chan struct{}
closeOnce sync.Once
}
func (conn *natFlowConn) Read([]byte) (int, error) {
<-conn.closed
return 0, io.EOF
}
func (conn *natFlowConn) Write(data []byte) (int, error) { return len(data), nil }
func (conn *natFlowConn) Close() error {
conn.closeOnce.Do(func() { close(conn.closed) })
return nil
}
func (conn *natFlowConn) LocalAddr() net.Addr { return natFlowAddr{} }
func (conn *natFlowConn) RemoteAddr() net.Addr { return natFlowAddr{} }
func (conn *natFlowConn) SetDeadline(time.Time) error { return nil }
func (conn *natFlowConn) SetReadDeadline(time.Time) error { return nil }
func (conn *natFlowConn) SetWriteDeadline(time.Time) error { return nil }
type natFlowAddr struct{}
func (natFlowAddr) Network() string { return "test" }
func (natFlowAddr) String() string { return "test" }
type natFlowAgent struct {
mu sync.Mutex
written bytes.Buffer
writeDone chan struct{}
once sync.Once
}
func (agent *natFlowAgent) Read([]byte) (int, error) { return 0, io.EOF }
func (agent *natFlowAgent) Write(data []byte) (int, error) {
agent.mu.Lock()
agent.written.Write(data)
agent.mu.Unlock()
agent.once.Do(func() { close(agent.writeDone) })
return len(data), nil
}
func (*natFlowAgent) Close() error { return nil }
func (agent *natFlowAgent) writtenBytes() []byte {
agent.mu.Lock()
defer agent.mu.Unlock()
return append([]byte(nil), agent.written.Bytes()...)
}
@@ -0,0 +1,226 @@
package controller
import (
"bytes"
"context"
"log"
"testing"
"time"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func setupDashboardCredentialTest(t *testing.T) {
t.Helper()
originalConf, originalDB, originalParser := singleton.Conf, singleton.DB, dashboardCredentialJWTParser
t.Cleanup(func() {
singleton.Conf, singleton.DB, dashboardCredentialJWTParser = originalConf, originalDB, originalParser
})
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{}}
singleton.Conf.JWTSecretKey = "dashboard-credential-classification-test-key"
singleton.Conf.JWTTimeout = 1
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
require.NoError(t, db.AutoMigrate(&model.APIToken{}))
singleton.DB = db
// Same construction path as routers(): jwt.New(initParams()).
authMiddleware, err := jwt.New(initParams())
require.NoError(t, err)
dashboardCredentialJWTParser = newDashboardCredentialJWTParser(authMiddleware)
}
// mintDashboardCredentialJWT signs a JWT with the dashboard key through
// initParams(), the same key source the production verifier uses. A negative
// timeout mints an already-expired token.
func mintDashboardCredentialJWT(t *testing.T, timeout time.Duration) string {
t.Helper()
params := initParams()
params.Timeout = timeout
mw, err := jwt.New(params)
require.NoError(t, err)
token, _, err := mw.TokenGenerator(nil)
require.NoError(t, err)
return token
}
func mintForeignJWT(t *testing.T) string {
t.Helper()
params := initParams()
params.Key = []byte("foreign-signing-key-not-held-by-the-dashboard")
mw, err := jwt.New(params)
require.NoError(t, err)
token, _, err := mw.TokenGenerator(nil)
require.NoError(t, err)
return token
}
func TestIsDashboardCredentialClassification(t *testing.T) {
setupDashboardCredentialTest(t)
dashboardJWT := mintDashboardCredentialJWT(t, time.Hour)
expiredDashboardJWT := mintDashboardCredentialJWT(t, -time.Hour)
foreignJWT := mintForeignJWT(t)
dashboardPAT := model.APITokenPrefix + "dashboard-credential-pat"
require.NoError(t, singleton.DB.Create(&model.APIToken{
UserID: 1,
Name: "nat-classification",
TokenHash: model.HashAPIToken(dashboardPAT),
}).Error)
t.Run("dashboard signed jwt is a dashboard credential", func(t *testing.T) {
require.True(t, IsDashboardCredential("Bearer "+dashboardJWT))
})
t.Run("expired dashboard signed jwt is still a dashboard credential", func(t *testing.T) {
// Signature verification ignores exp on purpose: an expired token is
// still proof the dashboard issued it.
require.True(t, IsDashboardCredential("Bearer "+expiredDashboardJWT))
})
t.Run("dashboard api token is a dashboard credential", func(t *testing.T) {
require.True(t, IsDashboardCredential("Bearer "+dashboardPAT))
})
t.Run("foreign signed jwt is not a dashboard credential", func(t *testing.T) {
require.False(t, IsDashboardCredential("Bearer "+foreignJWT))
})
t.Run("unknown api token is not a dashboard credential", func(t *testing.T) {
require.False(t, IsDashboardCredential("Bearer "+model.APITokenPrefix+"unknown-to-the-dashboard"))
})
t.Run("random string is not a dashboard credential", func(t *testing.T) {
require.False(t, IsDashboardCredential("Bearer random-not-a-token"))
})
t.Run("non bearer scheme is not a dashboard credential", func(t *testing.T) {
require.False(t, IsDashboardCredential("Basic "+dashboardJWT))
// Scheme matching is case-sensitive, as in both dashboard auth middlewares.
require.False(t, IsDashboardCredential("bearer "+dashboardJWT))
})
t.Run("empty or missing header is not a dashboard credential", func(t *testing.T) {
require.False(t, IsDashboardCredential(""))
require.False(t, IsDashboardCredential("Bearer "))
})
}
// TestIsDashboardCredentialValueClassification pins the scheme-less classifier
// that backs IsDashboardCredential and drives the ?token= query parameter and
// nz-jwt cookie channels at NAT ingress (see IsDashboardCredentialValue).
func TestIsDashboardCredentialValueClassification(t *testing.T) {
setupDashboardCredentialTest(t)
dashboardJWT := mintDashboardCredentialJWT(t, time.Hour)
expiredDashboardJWT := mintDashboardCredentialJWT(t, -time.Hour)
foreignJWT := mintForeignJWT(t)
dashboardPAT := model.APITokenPrefix + "dashboard-credential-value-pat"
require.NoError(t, singleton.DB.Create(&model.APIToken{
UserID: 1,
Name: "nat-value-classification",
TokenHash: model.HashAPIToken(dashboardPAT),
}).Error)
t.Run("dashboard signed jwt is a dashboard credential", func(t *testing.T) {
require.True(t, IsDashboardCredentialValue(dashboardJWT))
})
t.Run("expired dashboard signed jwt is still a dashboard credential", func(t *testing.T) {
require.True(t, IsDashboardCredentialValue(expiredDashboardJWT))
})
t.Run("dashboard api token is a dashboard credential", func(t *testing.T) {
require.True(t, IsDashboardCredentialValue(dashboardPAT))
})
t.Run("foreign signed jwt is not a dashboard credential", func(t *testing.T) {
require.False(t, IsDashboardCredentialValue(foreignJWT))
})
t.Run("unknown api token is not a dashboard credential", func(t *testing.T) {
require.False(t, IsDashboardCredentialValue(model.APITokenPrefix+"unknown-to-the-dashboard"))
})
t.Run("random string is not a dashboard credential", func(t *testing.T) {
require.False(t, IsDashboardCredentialValue("random-not-a-token"))
})
t.Run("empty value is not a dashboard credential", func(t *testing.T) {
require.False(t, IsDashboardCredentialValue(""))
})
t.Run("bearer scheme is not part of the value contract", func(t *testing.T) {
// The query and cookie channels carry the bare value; a value that
// itself starts with "Bearer " is not something the panel issues and
// fails signature verification.
require.False(t, IsDashboardCredentialValue("Bearer "+dashboardJWT))
})
}
func TestClassifyDashboardCredentialValuesBatchesPATLookup(t *testing.T) {
setupDashboardCredentialTest(t)
dashboardPAT := model.APITokenPrefix + "dashboard-batch-pat"
require.NoError(t, singleton.DB.Create(&model.APIToken{
UserID: 1,
Name: "nat-batch-classification",
TokenHash: model.HashAPIToken(dashboardPAT),
}).Error)
queryCount := 0
const callbackName = "test:count-dashboard-credential-batch-query"
require.NoError(t, singleton.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(*gorm.DB) {
queryCount++
}))
t.Cleanup(func() { singleton.DB.Callback().Query().Remove(callbackName) })
values := []string{
dashboardPAT,
model.APITokenPrefix + "unknown-one",
dashboardPAT,
model.APITokenPrefix + "unknown-two",
}
classified := ClassifyDashboardCredentialValues(context.Background(), values)
require.Equal(t, []bool{true, false, true, false}, classified)
require.Equal(t, 1, queryCount, "all PAT hashes in one request must use one database query")
}
func TestIsDashboardCredentialFailsClosedWhenParserUnavailable(t *testing.T) {
setupDashboardCredentialTest(t)
dashboardCredentialJWTParser = nil
token := mintDashboardCredentialJWT(t, time.Hour)
require.True(t, IsDashboardCredential("Bearer "+token))
}
func TestIsDashboardCredentialFailsClosedWhenAPITokenLookupUnavailable(t *testing.T) {
setupDashboardCredentialTest(t)
singleton.DB = nil
require.True(t, IsDashboardCredential("Bearer "+model.APITokenPrefix+"unresolvable"))
}
func TestIsDashboardCredentialFailsClosedOnDatabaseError(t *testing.T) {
setupDashboardCredentialTest(t)
sqlDB, err := singleton.DB.DB()
require.NoError(t, err)
require.NoError(t, sqlDB.Close())
var logs bytes.Buffer
originalOutput := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(originalOutput) })
plaintext := model.APITokenPrefix + "unresolvable-after-db-failure"
require.True(t, IsDashboardCredential("Bearer "+plaintext))
// The credential content itself must never reach the log.
require.NotContains(t, logs.String(), plaintext)
}
+23 -4
View File
@@ -30,6 +30,15 @@ func listDDNS(c *gin.Context) ([]*model.DDNSProfile, error) {
return nil, err
}
// 列表端点不回显写入态凭据:ddnsProfiles 是 copier 复制出的副本,置零安全,
// 不影响 singleton 内原始数据。
for _, p := range ddnsProfiles {
p.AccessSecret = ""
p.WebhookURL = ""
p.WebhookRequestBody = ""
p.WebhookHeaders = ""
}
return ddnsProfiles, nil
}
@@ -137,12 +146,22 @@ func updateDDNS(c *gin.Context) (any, error) {
p.Provider = df.Provider
p.Domains = df.Domains
p.AccessID = df.AccessID
p.AccessSecret = df.AccessSecret
p.WebhookURL = df.WebhookURL
p.WebhookMethod = df.WebhookMethod
p.WebhookRequestType = df.WebhookRequestType
p.WebhookRequestBody = df.WebhookRequestBody
p.WebhookHeaders = df.WebhookHeaders
// 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。
if df.AccessSecret != "" {
p.AccessSecret = df.AccessSecret
}
if df.WebhookHeaders != "" {
p.WebhookHeaders = df.WebhookHeaders
}
if df.WebhookURL != "" {
p.WebhookURL = df.WebhookURL
}
if df.WebhookRequestBody != "" {
p.WebhookRequestBody = df.WebhookRequestBody
}
for n, domain := range p.Domains {
// IDN to ASCII
+29 -1
View File
@@ -3,6 +3,7 @@ package controller
import (
"encoding/json"
"fmt"
"strconv"
"time"
@@ -46,12 +47,13 @@ func GetDomainList(c *gin.Context) (any, error) {
if d.BillingData != nil {
var billing model.BillingDataMod
if json.Unmarshal(d.BillingData, &billing) == nil && billing.EndDate != "" {
if endDate, err := time.Parse(time.RFC3339, billing.EndDate); err == nil {
if endDate, err := singleton.ParseFlexibleDate(billing.EndDate); err == nil {
daysLeft := int(time.Until(endDate).Hours() / 24)
apiDomain.ExpiresInDays = &daysLeft
}
}
}
response = append(response, apiDomain)
}
@@ -118,3 +120,29 @@ func UpdateDomainInfo(c *gin.Context) (any, error) {
return singleton.UpdateDomain(domainID, req)
}
func SyncDomainWHOIS(c *gin.Context) (any, error) {
domainID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
return nil, newGormError("无效的域名ID")
}
domain, err := singleton.GetDomainByID(domainID)
if err != nil {
return nil, newGormError("未找到域名: %s", err.Error())
}
if err := singleton.SyncDomainWHOIS(domain); err != nil {
return nil, fmt.Errorf("Whois 同步失败: %v", err)
}
return domain, nil
}
func SyncAllDomains(c *gin.Context) (any, error) {
singleton.SyncAllDomains()
// 手动触发到期检查,方便调试
singleton.CronJobForDomainStatus()
singleton.CronJobForServerStatus()
return nil, nil
}
+48 -16
View File
@@ -6,7 +6,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"github.com/gorilla/websocket"
"github.com/hashicorp/go-uuid"
"github.com/nezhahq/nezha/model"
@@ -16,6 +15,19 @@ import (
"github.com/nezhahq/nezha/service/singleton"
)
// The official file-manager client uploads in 1 MiB WebSocket messages. Cap
// each complete message at that protocol boundary so gorilla/websocket cannot
// buffer an attacker-sized frame before the IO stream relay sees it.
const fileManagerWebSocketInputLimit int64 = 1024 * 1024
type websocketReadLimiter interface {
SetReadLimit(limit int64)
}
func limitFileManagerWebSocketInput(conn websocketReadLimiter) {
conn.SetReadLimit(fileManagerWebSocketInputLimit)
}
// Create FM session
// @Summary Create FM session
// @Description Create an "attached" FM. It is advised to only call this within a terminal session.
@@ -24,8 +36,9 @@ import (
// @Param id query uint true "Server ID"
// @Produce json
// @Success 200 {object} model.CreateFMResponse
// @Router /file [get]
// @Router /file [post]
func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
prepareAgentcompatCapabilityHeader(c)
idStr := c.Query("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
@@ -33,7 +46,10 @@ func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
}
server, _ := singleton.ServerShared.Get(id)
if server == nil || server.TaskStream == nil {
if server == nil {
return nil, singleton.Localizer.ErrorT("server not found or not connected")
}
if server.GetTaskStream() == nil {
return nil, singleton.Localizer.ErrorT("server not found or not connected")
}
@@ -41,20 +57,33 @@ func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
if server.IsTelemetryOnly() {
return nil, singleton.Localizer.ErrorT("file manager is disabled: server is in telemetry-only mode")
}
streamId, err := uuid.GenerateUUID()
if err != nil {
return nil, err
}
rpc.NezhaHandlerSingleton.CreateStream(streamId)
cleanup, err := createIOStreamWithAgentcompatCapability(c, streamId, getUid(c), server.ID, rpc.AgentCompatCapabilityFileManager)
if err != nil {
return nil, err
}
fmData, _ := json.Marshal(&model.TaskFM{
fmData, err := json.Marshal(&model.TaskFM{
StreamID: streamId,
})
if err := server.TaskStream.Send(&proto.Task{
if err != nil {
// A stream is owned by the caller only after this function succeeds.
cleanup()
return nil, err
}
if err := server.SendTask(&proto.Task{
Type: model.TaskTypeFM,
Data: string(fmData),
}); err != nil {
cleanup()
return nil, err
}
@@ -72,6 +101,12 @@ func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
// @Router /ws/file/{id} [get]
func fmStream(c *gin.Context) (any, error) {
streamId := c.Param("id")
// GHSA-style fix: io_stream sessions must be reachable only by their creator
// (or an admin). Without this, any authenticated user who learns a stream
// UUID can hijack a live file-manager session on the target server.
if !streamAttachAllowedForRequest(c, streamId) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
return nil, err
}
@@ -81,18 +116,15 @@ func fmStream(c *gin.Context) (any, error) {
if err != nil {
return nil, newWsError("%v", err)
}
defer wsConn.Close()
limitFileManagerWebSocketInput(wsConn)
conn := websocketx.NewConn(wsConn)
pingTransport := newWebsocketPingTransport(conn, wsConn.Close)
stopPing := startWebsocketPingTicker(c.Request.Context(), time.Second*10, pingTransport)
go func() {
// PING 保活
for {
if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
return
}
time.Sleep(time.Second * 10)
}
}()
deregisterPAT := registerPATConnection(c, func() { _ = pingTransport.Close() })
defer deregisterPAT()
// Join the ping worker before PAT and WebSocket cleanup can close its writer.
defer stopPing()
if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
return nil, newWsError("%v", err)
@@ -0,0 +1,149 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
pb "github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/singleton"
)
// fakeTaskStream is the minimum stub of pb.NezhaService_RequestTaskServer
// required to make a server look "online" to forceUpdateServer. Only Send is
// called; we capture its argument so the test can verify the upgrade task is
// NOT dispatched for foreign IDs.
type fakeTaskStream struct {
pb.NezhaService_RequestTaskServer
sentTasks []*pb.Task
}
func (f *fakeTaskStream) Send(t *pb.Task) error {
f.sentTasks = append(f.sentTasks, t)
return nil
}
// setupServerOwnershipFixture seeds an in-memory DB with alice's server
// (UserID=100, ID=1). The returned stream is wired in so the server is
// "online" — i.e. exercises the path that previously returned permission
// denied for foreign callers (the actual leak channel).
func setupServerOwnershipFixture(t *testing.T) (stream *fakeTaskStream, reset func()) {
t.Helper()
if singleton.Localizer == nil {
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
}
originalDB := singleton.DB
originalShared := singleton.ServerShared
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&model.Server{}))
assert.NoError(t, db.Create(&model.Server{
Common: model.Common{ID: 1, UserID: 100},
Name: "alice-online",
}).Error)
singleton.DB = db
singleton.ServerShared = singleton.NewServerClass()
alice, _ := singleton.ServerShared.Get(1)
stream = &fakeTaskStream{}
alice.SetTaskStream(stream)
return stream, func() {
singleton.DB = originalDB
singleton.ServerShared = originalShared
}
}
func runForceUpdate(t *testing.T, callerID uint64, ids []uint64) []byte {
t.Helper()
r := gin.New()
r.Use(func(c *gin.Context) {
setAuthUser(c, callerID, model.RoleMember)
c.Next()
})
r.POST("/force-update/server", commonHandler(forceUpdateServer))
body, _ := json.Marshal(ids)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/force-update/server", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
return w.Body.Bytes()
}
type forceUpdateBody struct {
Success bool `json:"success"`
Error string `json:"error"`
Data struct {
Offline []uint64 `json:"offline"`
Success []uint64 `json:"success"`
Failure []uint64 `json:"failure"`
} `json:"data"`
}
func decodeForceUpdate(t *testing.T, body []byte) forceUpdateBody {
t.Helper()
var resp forceUpdateBody
assert.NoError(t, json.Unmarshal(body, &resp))
return resp
}
// Core regression: bob submitting alice's online server ID must NOT produce
// a distinct response from bob submitting an unknown ID. The original code
// returned "permission denied" for the former and a structured success/Offline
// response for the latter — that delta is the enumeration oracle for server
// IDs and online state.
func TestForceUpdateServerOnlineForeignIDIndistinguishableFromUnknown(t *testing.T) {
gin.SetMode(gin.TestMode)
_, reset := setupServerOwnershipFixture(t)
defer reset()
const bobID = uint64(200)
foreignResp := decodeForceUpdate(t, runForceUpdate(t, bobID, []uint64{1})) // alice's online
unknownResp := decodeForceUpdate(t, runForceUpdate(t, bobID, []uint64{9999})) // does not exist
assert.Equal(t, foreignResp.Success, unknownResp.Success,
"top-level success flag must not differ between foreign-online and unknown IDs")
assert.Equal(t, foreignResp.Error, unknownResp.Error,
"error string must not differ — distinct error reveals existence/state of foreign servers")
assert.Equal(t, foreignResp.Data.Success, unknownResp.Data.Success)
assert.Equal(t, foreignResp.Data.Failure, unknownResp.Data.Failure)
}
// Submitting a foreign online server must NOT actually trigger the upgrade
// task on it — that would be a write primitive on someone else's machine.
func TestForceUpdateServerForeignOnlineDoesNotDispatchUpgrade(t *testing.T) {
gin.SetMode(gin.TestMode)
stream, reset := setupServerOwnershipFixture(t)
defer reset()
_ = runForceUpdate(t, 200, []uint64{1}) // bob hits alice's online server
assert.Empty(t, stream.sentTasks,
"foreign server must not receive the upgrade task even when online")
}
// Sanity: owner submitting their own online server must still get the upgrade
// dispatched and a structured success response — the hardening must not
// regress the legitimate case.
func TestForceUpdateServerOwnerOnlineStillDispatches(t *testing.T) {
gin.SetMode(gin.TestMode)
stream, reset := setupServerOwnershipFixture(t)
defer reset()
resp := decodeForceUpdate(t, runForceUpdate(t, 100, []uint64{1})) // alice on her own server
assert.True(t, resp.Success)
assert.Equal(t, []uint64{1}, resp.Data.Success)
assert.Empty(t, resp.Data.Offline)
assert.Empty(t, resp.Data.Failure)
assert.Len(t, stream.sentTasks, 1, "owner's own server must receive the upgrade task exactly once")
}
@@ -0,0 +1,25 @@
package controller
import (
"net/http"
"strings"
"testing"
)
// 前端在 main.tsx 注册了 /dashboard/settings/api-tokens,但后端 fallback 白名单
// 漏加这条会让用户直接刷新该页面拿到 HTTP 404(body 还是 index.html)。
// controller.go 旁边的注释明确说「新增前端路由时必须在 main.tsx 与这里同步加」。
func TestFallbackToFrontend_APITokensRouteReturns200(t *testing.T) {
t.Chdir(t.TempDir())
router := newFrontendFallbackTestRouter(t)
w := performFrontendFallbackRequest(t, router, "/dashboard/settings/api-tokens")
if w.Code != http.StatusOK {
t.Fatalf("/dashboard/settings/api-tokens fallback status = %d, want 200 "+
"(front-end main.tsx registered the route — backend SPA fallback regex must mirror it)",
w.Code)
}
if !strings.Contains(w.Body.String(), "admin index") {
t.Fatalf("/dashboard/settings/api-tokens must serve admin index.html, got %q", w.Body.String())
}
}
@@ -0,0 +1,133 @@
package controller
import (
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func newFrontendFallbackTestRouter(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
originalConf := singleton.Conf
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{
ConfigDashboard: model.ConfigDashboard{
AdminTemplate: "admin-dist",
UserTemplate: "user-dist",
},
}}
t.Cleanup(func() { singleton.Conf = originalConf })
writeFrontendFallbackTestFile(t, "admin-dist/index.html", "<html>admin index</html>")
writeFrontendFallbackTestFile(t, "admin-dist/assets/app.js", "console.log('admin asset')")
writeFrontendFallbackTestFile(t, "user-dist/index.html", "<html>user index</html>")
writeFrontendFallbackTestFile(t, "data/config.yaml", "jwt_secret_key: traversal-secret")
r := gin.New()
r.NoRoute(fallbackToFrontend(testFrontendDist{}))
return r
}
func writeFrontendFallbackTestFile(t *testing.T, name, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
t.Fatalf("create fixture directory: %v", err)
}
if err := os.WriteFile(name, []byte(content), 0o644); err != nil {
t.Fatalf("write fixture file: %v", err)
}
}
type testFrontendDist struct{}
func (testFrontendDist) Open(string) (fs.File, error) {
return nil, fs.ErrNotExist
}
func performFrontendFallbackRequest(t *testing.T, router *gin.Engine, target string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil)
router.ServeHTTP(w, req)
return w
}
func TestFallbackToFrontendBlocksDashboardTraversal(t *testing.T) {
t.Chdir(t.TempDir())
router := newFrontendFallbackTestRouter(t)
tests := []string{
"/dashboard../data/config.yaml",
"/dashboard%2e%2e/data/config.yaml",
"/dashboard%2e%2e%2fdata%2fconfig.yaml",
"/dashboard/../data/config.yaml",
"/dashboard/%2e%2e/data/config.yaml",
"/dashboard../assets/app.js",
}
for _, target := range tests {
t.Run(target, func(t *testing.T) {
w := performFrontendFallbackRequest(t, router, target)
body := w.Body.String()
if strings.Contains(body, "traversal-secret") || strings.Contains(body, "jwt_secret_key") || strings.Contains(body, "admin asset") {
t.Fatalf("%s leaked protected content with status %d: %q", target, w.Code, body)
}
})
}
}
func TestFallbackToFrontendBlocksUserTraversal(t *testing.T) {
t.Chdir(t.TempDir())
router := newFrontendFallbackTestRouter(t)
tests := []string{
"/../data/config.yaml",
"/%2e%2e/data/config.yaml",
"/%2e%2e%2fdata%2fconfig.yaml",
"/../admin-dist/assets/app.js",
"/%2e%2e/admin-dist/assets/app.js",
}
for _, target := range tests {
t.Run(target, func(t *testing.T) {
w := performFrontendFallbackRequest(t, router, target)
body := w.Body.String()
if strings.Contains(body, "traversal-secret") || strings.Contains(body, "jwt_secret_key") || strings.Contains(body, "admin asset") {
t.Fatalf("%s leaked protected content with status %d: %q", target, w.Code, body)
}
})
}
}
func TestFallbackToFrontendPreservesDashboardRoutes(t *testing.T) {
t.Chdir(t.TempDir())
router := newFrontendFallbackTestRouter(t)
w := performFrontendFallbackRequest(t, router, "/dashboard")
if w.Code != http.StatusMovedPermanently {
t.Fatalf("/dashboard status = %d, want %d", w.Code, http.StatusMovedPermanently)
}
if location := w.Header().Get("Location"); location != "/dashboard/" {
t.Fatalf("/dashboard Location = %q, want /dashboard/", location)
}
w = performFrontendFallbackRequest(t, router, "/dashboard/")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "admin index") {
t.Fatalf("/dashboard/ status = %d body = %q, want admin index", w.Code, w.Body.String())
}
w = performFrontendFallbackRequest(t, router, "/dashboard/assets/app.js")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "admin asset") {
t.Fatalf("/dashboard/assets/app.js status = %d body = %q, want admin asset", w.Code, w.Body.String())
}
}
@@ -0,0 +1,214 @@
//go:build agentcompat
package controller
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
)
func TestAgentcompatIOStreamStateRoutesRequirePATAndReturnTypedState(t *testing.T) {
cleanup, userID := setupMCPTest(t)
defer cleanup()
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeInventoryRead}, nil)
gin.SetMode(gin.TestMode)
router := gin.New()
registerAgentcompatRoutes(router)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
unauthenticated, err := http.NewRequest(http.MethodGet, server.URL+"/agentcompat/io-stream-state", nil)
require.NoError(t, err)
response, err := server.Client().Do(unauthenticated)
require.NoError(t, err)
require.Equal(t, http.StatusUnauthorized, response.StatusCode)
response.Body.Close()
request, err := http.NewRequest(http.MethodGet, server.URL+"/agentcompat/io-stream-state", nil)
require.NoError(t, err)
request.Header.Set("Authorization", "Bearer "+token)
response, err = server.Client().Do(request)
require.NoError(t, err)
defer response.Body.Close()
var envelope model.CommonResponse[rpc.IOStreamState]
responseBody, readErr := io.ReadAll(response.Body)
require.NoError(t, readErr)
require.NotContains(t, string(responseBody), "route-wait")
require.NoError(t, json.Unmarshal(responseBody, &envelope))
require.Equal(t, http.StatusOK, response.StatusCode)
require.True(t, envelope.Success)
require.Equal(t, rpc.IOStreamState{}, envelope.Data)
}
func TestAgentcompatIOStreamStateWaitRouteWakesAfterClose(t *testing.T) {
cleanup, userID := setupMCPTest(t)
defer cleanup()
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeInventoryRead}, nil)
require.NoError(t, rpc.NezhaHandlerSingleton.CreateStream("route-wait", 1, 7))
gin.SetMode(gin.TestMode)
router := gin.New()
registerAgentcompatRoutes(router)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
body, err := json.Marshal(rpc.IOStreamStateExpectation{ExpectedCount: rpc.ExpectedIOStreamCount(0), AbsentStreamID: "route-wait"})
require.NoError(t, err)
requestContext, cancel := context.WithCancel(context.Background())
defer cancel()
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, server.URL+"/agentcompat/io-stream-state", bytes.NewReader(body))
require.NoError(t, err)
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
result := make(chan *http.Response, 1)
go func() {
response, requestErr := server.Client().Do(request)
if requestErr == nil {
result <- response
}
}()
rpc.NezhaHandlerSingleton.CloseStream("route-wait")
response := <-result
defer response.Body.Close()
var envelope model.CommonResponse[rpc.IOStreamState]
responseBody, readErr := io.ReadAll(response.Body)
require.NoError(t, readErr)
require.NotContains(t, string(responseBody), "route-wait")
require.NoError(t, json.Unmarshal(responseBody, &envelope))
require.True(t, envelope.Success)
require.Equal(t, 0, envelope.Data.Count)
require.Equal(t, uint64(2), envelope.Data.Generation)
}
func TestAgentcompatIOStreamStateCreateWaitRouteWakesAfterCreate(t *testing.T) {
cleanup, userID := setupMCPTest(t)
defer cleanup()
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeInventoryRead}, nil)
gin.SetMode(gin.TestMode)
router := gin.New()
waitCaptured := make(chan struct{})
var observerOnce sync.Once
rpc.NezhaHandlerSingleton.SetIOStreamStateWaitObserverForAgentcompat(func() {
observerOnce.Do(func() { close(waitCaptured) })
})
t.Cleanup(func() {
rpc.NezhaHandlerSingleton.SetIOStreamStateWaitObserverForAgentcompat(nil)
})
registerAgentcompatRoutes(router)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
body := bytes.NewBufferString(`{"expected_count":1}`)
requestContext, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, server.URL+"/agentcompat/io-stream-state", body)
require.NoError(t, err)
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
result := make(chan *http.Response, 1)
go func() {
response, requestErr := server.Client().Do(request)
if requestErr == nil {
result <- response
}
}()
select {
case <-waitCaptured:
case <-requestContext.Done():
t.Fatal(requestContext.Err())
}
require.NoError(t, rpc.NezhaHandlerSingleton.CreateStream("route-create", 1, 7))
var response *http.Response
select {
case response = <-result:
case <-requestContext.Done():
t.Fatal(requestContext.Err())
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
var envelope model.CommonResponse[rpc.IOStreamState]
require.NoError(t, json.Unmarshal(responseBody, &envelope))
require.True(t, envelope.Success)
require.Equal(t, 1, envelope.Data.Count)
require.Equal(t, uint64(1), envelope.Data.Generation)
require.NotContains(t, string(responseBody), "route-create")
}
func TestAgentcompatIOStreamStateRejectsInvalidExpectationAndCancellation(t *testing.T) {
cleanup, userID := setupMCPTest(t)
defer cleanup()
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
_, token := mkToken(t, userID, []string{model.ScopeInventoryRead}, nil)
gin.SetMode(gin.TestMode)
router := gin.New()
registerAgentcompatRoutes(router)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
for _, rawBody := range []string{`{}`, `{"expected_count":null}`, `{"expected_count":-1,"absent_stream_id":"private-stream-id"}`} {
body := bytes.NewBufferString(rawBody)
request, err := http.NewRequest(http.MethodPost, server.URL+"/agentcompat/io-stream-state", body)
require.NoError(t, err)
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response, err := server.Client().Do(request)
require.NoError(t, err)
responseBody, readErr := io.ReadAll(response.Body)
response.Body.Close()
require.NoError(t, readErr)
var envelope model.CommonResponse[rpc.IOStreamState]
require.NoError(t, json.Unmarshal(responseBody, &envelope))
require.False(t, envelope.Success)
require.NotEmpty(t, envelope.Error)
require.NotContains(t, string(responseBody), "private-stream-id")
}
body := bytes.NewBufferString(`{"absent_stream_id":"absence-only"}`)
request, err := http.NewRequest(http.MethodPost, server.URL+"/agentcompat/io-stream-state", body)
require.NoError(t, err)
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response, err := server.Client().Do(request)
require.NoError(t, err)
defer response.Body.Close()
var envelope model.CommonResponse[rpc.IOStreamState]
require.NoError(t, json.NewDecoder(response.Body).Decode(&envelope))
require.True(t, envelope.Success)
require.Equal(t, 0, envelope.Data.Count)
unauthenticatedBody := bytes.NewBufferString(`{"expected_count":0}`)
unauthenticatedPost, err := http.NewRequest(http.MethodPost, server.URL+"/agentcompat/io-stream-state", unauthenticatedBody)
require.NoError(t, err)
unauthenticatedPost.Header.Set("Content-Type", "application/json")
unauthenticatedResponse, err := server.Client().Do(unauthenticatedPost)
require.NoError(t, err)
unauthenticatedResponse.Body.Close()
require.Equal(t, http.StatusUnauthorized, unauthenticatedResponse.StatusCode)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, waitErr := rpc.NezhaHandlerSingleton.WaitForIOStreamState(ctx, rpc.IOStreamStateExpectation{ExpectedCount: rpc.ExpectedIOStreamCount(1)})
require.True(t, errors.Is(waitErr, context.Canceled))
}
+140 -32
View File
@@ -1,6 +1,8 @@
package controller
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"time"
@@ -10,32 +12,89 @@ import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/idcodec"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/service/singleton"
)
const (
jwtClaimUserID = "uid"
jwtClaimKeyID = "keyId"
jwtKeyIDBytes = 32
)
func uaHash(c *gin.Context) string {
sum := sha256.Sum256([]byte(c.Request.UserAgent()))
return hex.EncodeToString(sum[:])
}
func issueJWTSession(c *gin.Context, user *model.User, jwtTimeoutHours int) (map[string]interface{}, error) {
keyID, err := utils.GenerateRandomString(jwtKeyIDBytes)
if err != nil {
return nil, err
}
// encodedUID is reversible Sqids obfuscation keyed by JWTSecretKey, NOT
// a one-way hash. It exists to defeat enumeration on the wire, not to
// keep the uid confidential — see L2 note in idcodec docs.
encodedUID, err := idcodec.Encode(user.ID)
if err != nil {
return nil, err
}
now := time.Now()
sess := model.JWTSession{
KeyID: keyID,
UserID: user.ID,
IP: c.GetString(model.CtxKeyRealIPStr),
UAHash: uaHash(c),
TokenVersion: user.TokenVersion,
ExpiresAt: now.Add(time.Hour * time.Duration(jwtTimeoutHours)),
CreatedAt: now,
LastUsedAt: now,
}
if err := singleton.DB.Create(&sess).Error; err != nil {
return nil, err
}
return map[string]interface{}{
jwtClaimUserID: encodedUID,
jwtClaimKeyID: keyID,
}, nil
}
func initParams() *jwt.GinJWTMiddleware {
return &jwt.GinJWTMiddleware{
Realm: singleton.Conf.SiteName,
Key: []byte(singleton.Conf.JWTSecretKey),
CookieName: "nz-jwt",
SendCookie: true,
Timeout: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
MaxRefresh: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
IdentityKey: model.CtxKeyAuthorizedUser,
PayloadFunc: payloadFunc(),
Realm: singleton.Conf.SiteName,
Key: []byte(singleton.Conf.JWTSecretKey),
CookieName: "nz-jwt",
SendCookie: true,
// Pin the signing algorithm so a future library default change (or an
// `alg: none` confusion attempt) cannot weaken token validation.
SigningAlgorithm: "HS256",
// Lax keeps OAuth callback redirects (top-level GET navigations from
// the provider domain) working while blocking cross-site POST CSRF.
// HttpOnly/Secure are intentionally left default: the frontend reads
// `!!document.cookie` for login-state display and many deployments
// terminate TLS at a proxy upstream — both warrant a separate change.
CookieSameSite: http.SameSiteLaxMode,
Timeout: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
MaxRefresh: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
IdentityKey: model.CtxKeyAuthorizedUser,
PayloadFunc: payloadFunc(),
IdentityHandler: identityHandler(),
Authenticator: authenticator(),
Authorizator: authorizator(),
Unauthorized: unauthorized(),
TokenLookup: "header: Authorization, query: token, cookie: nz-jwt",
TokenHeadName: "Bearer",
TimeFunc: time.Now,
// query: token still accepted because the WebSocket browser API
// cannot set Authorization headers; removing it would break the
// /ws/* routes until the frontend migrates to cookie auth.
TokenLookup: "header: Authorization, query: token, cookie: nz-jwt",
TokenHeadName: "Bearer",
TimeFunc: time.Now,
LoginResponse: func(c *gin.Context, code int, token string, expire time.Time) {
setCSRFCookie(c)
c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{
Success: true,
Data: model.LoginResponse{
@@ -50,10 +109,8 @@ func initParams() *jwt.GinJWTMiddleware {
func payloadFunc() func(data any) jwt.MapClaims {
return func(data any) jwt.MapClaims {
if v, ok := data.(string); ok {
return jwt.MapClaims{
model.CtxKeyAuthorizedUser: v,
}
if v, ok := data.(map[string]interface{}); ok {
return v
}
return jwt.MapClaims{}
}
@@ -62,11 +119,54 @@ func payloadFunc() func(data any) jwt.MapClaims {
func identityHandler() func(c *gin.Context) any {
return func(c *gin.Context) any {
claims := jwt.ExtractClaims(c)
userId := claims[model.CtxKeyAuthorizedUser].(string)
var user model.User
if err := singleton.DB.First(&user, userId).Error; err != nil {
keyID, ok := claims[jwtClaimKeyID].(string)
if !ok || keyID == "" {
return nil
}
encodedUID, ok := claims[jwtClaimUserID].(string)
if !ok || encodedUID == "" {
return nil
}
claimUID, err := idcodec.Decode(encodedUID)
if err != nil {
return nil
}
var sess model.JWTSession
if err := singleton.DB.First(&sess, "key_id = ?", keyID).Error; err != nil {
return nil
}
if sess.RevokedAt != nil {
return nil
}
now := time.Now()
if now.After(sess.ExpiresAt) {
return nil
}
if claimUID != sess.UserID {
return nil
}
currentIP := c.GetString(model.CtxKeyRealIPStr)
if !singleton.Conf.JWTIPChangeAllowed() && sess.IP != currentIP {
c.Set(model.CtxKeyIsIPMismatch, true)
return nil
}
var user model.User
if err := singleton.DB.First(&user, sess.UserID).Error; err != nil {
return nil
}
if user.TokenVersion != sess.TokenVersion {
return nil
}
_ = singleton.DB.Model(&model.JWTSession{}).
Where("key_id = ?", keyID).
Update("last_used_at", now).Error
c.Set(jwtClaimKeyID, keyID)
return &user
}
}
@@ -90,7 +190,7 @@ func authenticator() func(c *gin.Context) (any, error) {
var user model.User
realip := c.GetString(model.CtxKeyRealIPStr)
if err := singleton.DB.Select("id", "password", "reject_password").Where("username = ?", loginVals.Username).First(&user).Error; err != nil {
if err := singleton.DB.Select("id", "password", "reject_password", "token_version").Where("username = ?", loginVals.Username).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
model.BlockIP(singleton.DB, realip, model.WAFBlockReasonTypeLoginFail, model.BlockIDUnknownUser)
}
@@ -109,7 +209,8 @@ func authenticator() func(c *gin.Context) (any, error) {
model.UnblockIP(singleton.DB, realip, model.BlockIDUnknownUser)
model.UnblockIP(singleton.DB, realip, int64(user.ID))
return utils.Itoa(user.ID), nil
return issueJWTSession(c, &user, singleton.Conf.JWTTimeout)
}
}
@@ -137,8 +238,17 @@ func unauthorized() func(c *gin.Context, code int, message string) {
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[model.LoginResponse]
// @Router /refresh-token [get]
// @Router /refresh-token [post]
func refreshResponse(c *gin.Context, code int, token string, expire time.Time) {
if keyID := c.GetString(jwtClaimKeyID); keyID != "" {
_ = singleton.DB.Model(&model.JWTSession{}).
Where("key_id = ?", keyID).
Updates(map[string]interface{}{
"expires_at": expire,
"last_used_at": time.Now(),
}).Error
}
setCSRFCookie(c)
c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{
Success: true,
Data: model.LoginResponse{
@@ -152,25 +262,27 @@ func fallbackAuthMiddleware(mw *jwt.GinJWTMiddleware) func(c *gin.Context) {
return func(c *gin.Context) {
claims, err := mw.GetClaimsFromJWT(c)
if err != nil {
c.Next()
return
}
switch v := claims["exp"].(type) {
case nil:
c.Next()
return
case float64:
if int64(v) < mw.TimeFunc().Unix() {
c.Next()
return
}
case json.Number:
n, err := v.Int64()
if err != nil {
return
}
if n < mw.TimeFunc().Unix() {
if err != nil || n < mw.TimeFunc().Unix() {
c.Next()
return
}
default:
c.Next()
return
}
@@ -178,13 +290,9 @@ func fallbackAuthMiddleware(mw *jwt.GinJWTMiddleware) func(c *gin.Context) {
identity := mw.IdentityHandler(c)
if identity != nil {
model.UnblockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.BlockIDToken)
realIP := c.GetString(model.CtxKeyRealIPStr)
model.UnblockIP(singleton.DB, realIP, model.BlockIDToken)
c.Set(mw.IdentityKey, identity)
} else {
if err := model.BlockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken); err != nil {
waf.ShowBlockPage(c, err)
return
}
}
c.Next()
@@ -0,0 +1,421 @@
package controller
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"time"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/idcodec"
"github.com/nezhahq/nezha/service/singleton"
)
const jwtSessionTestMasterKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
func setupJWTSessionTest(t *testing.T) (cleanup func()) {
t.Helper()
require.NoError(t, idcodec.Init([]byte(jwtSessionTestMasterKey)))
originalDB := singleton.DB
originalConf := singleton.Conf
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.JWTSession{}, &model.WAF{}))
singleton.DB = db
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{JWTTimeout: 1}}
require.NoError(t, db.Create(&model.User{
Common: model.Common{ID: 100},
Username: "victim",
Role: model.RoleMember,
TokenVersion: 7,
}).Error)
return func() {
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Conf = originalConf
}
}
func newCtxForUser(userID uint64, ip, ua string) *gin.Context {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("GET", "/", nil)
ctx.Request.Header.Set("User-Agent", ua)
ctx.Set(model.CtxKeyRealIPStr, ip)
if userID != 0 {
ctx.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: userID}})
}
return ctx
}
func TestIssueJWTSessionWritesRow(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "test-ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
hashUID, _ := claims[jwtClaimUserID].(string)
keyID, _ := claims[jwtClaimKeyID].(string)
assert.NotEqual(t, "100", hashUID, "uid claim must be obfuscated, not raw integer")
got, err := idcodec.Decode(hashUID)
require.NoError(t, err)
assert.Equal(t, uint64(100), got)
var sess model.JWTSession
require.NoError(t, singleton.DB.First(&sess, "key_id = ?", keyID).Error)
assert.Equal(t, uint64(100), sess.UserID)
assert.Equal(t, "1.2.3.4", sess.IP)
assert.Equal(t, uint64(7), sess.TokenVersion)
assert.True(t, sess.ExpiresAt.After(time.Now()))
}
func TestIdentityHandlerHappyPath(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
verify := newCtxForUser(0, "1.2.3.4", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: claims[jwtClaimUserID],
jwtClaimKeyID: claims[jwtClaimKeyID],
})
identity := identityHandler()(verify)
require.NotNil(t, identity, "happy path must return user identity")
u := identity.(*model.User)
assert.Equal(t, uint64(100), u.ID)
}
func TestIdentityHandlerRejectsMismatchedClaimUID(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
forgedUID, err := idcodec.Encode(999)
require.NoError(t, err)
verify := newCtxForUser(0, "1.2.3.4", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: forgedUID,
jwtClaimKeyID: claims[jwtClaimKeyID],
})
identity := identityHandler()(verify)
assert.Nil(t, identity, "claim uid not matching session.user_id must reject")
}
func TestIdentityHandlerRejectsRevokedSession(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
keyID := claims[jwtClaimKeyID].(string)
require.NoError(t, singleton.RevokeJWTSession(keyID))
verify := newCtxForUser(0, "1.2.3.4", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: claims[jwtClaimUserID],
jwtClaimKeyID: claims[jwtClaimKeyID],
})
identity := identityHandler()(verify)
assert.Nil(t, identity, "revoked session must reject")
}
func TestIdentityHandlerRejectsTokenVersionBump(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
require.NoError(t, singleton.DB.Model(&model.User{}).
Where("id = ?", 100).
Update("token_version", 8).Error)
verify := newCtxForUser(0, "1.2.3.4", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: claims[jwtClaimUserID],
jwtClaimKeyID: claims[jwtClaimKeyID],
})
identity := identityHandler()(verify)
assert.Nil(t, identity, "session whose TokenVersion is stale must reject")
}
func TestIdentityHandlerFlagsIPMismatch(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
verify := newCtxForUser(0, "9.9.9.9", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: claims[jwtClaimUserID],
jwtClaimKeyID: claims[jwtClaimKeyID],
})
identity := identityHandler()(verify)
assert.Nil(t, identity, "IP mismatch must reject")
assert.True(t, verify.GetBool(model.CtxKeyIsIPMismatch))
}
func TestIdentityHandlerAllowsIPMismatchWhenConfigured(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
singleton.Conf.SetJWTIPChangeAllowed(true)
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
verify := newCtxForUser(0, "9.9.9.9", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: claims[jwtClaimUserID],
jwtClaimKeyID: claims[jwtClaimKeyID],
})
identity := identityHandler()(verify)
require.NotNil(t, identity, "IP mismatch must be accepted when allow_jwt_ip_change is enabled")
assert.False(t, verify.GetBool(model.CtxKeyIsIPMismatch))
}
func TestIdentityHandlerConcurrentIPChangeSetting(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
stop, done := make(chan struct{}), make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-stop:
return
default:
singleton.Conf.SetJWTIPChangeAllowed(true)
singleton.Conf.SetJWTIPChangeAllowed(false)
}
}
}()
defer func() {
close(stop)
<-done
}()
for i := 0; i < 100; i++ {
verify := newCtxForUser(0, "1.2.3.4", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims(claims))
require.NotNil(t, identityHandler()(verify), "matching IP must pass during setting updates")
}
}
func TestIdentityHandlerRejectsUnknownKeyID(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
hashUID, err := idcodec.Encode(100)
require.NoError(t, err)
verify := newCtxForUser(0, "1.2.3.4", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: hashUID,
jwtClaimKeyID: "this-key-id-was-never-issued",
})
identity := identityHandler()(verify)
assert.Nil(t, identity, "key id absent from sessions table must reject (no oracle to confirm secret)")
}
func TestAuthenticatorPersistsCurrentTokenVersion(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
pw, err := bcrypt.GenerateFromPassword([]byte("correct horse"), bcrypt.MinCost)
require.NoError(t, err)
require.NoError(t, singleton.DB.Model(&model.User{}).
Where("id = ?", 100).
Update("password", string(pw)).Error)
ctx := newCtxForUser(0, "1.2.3.4", "ua")
body, _ := json.Marshal(model.LoginRequest{Username: "victim", Password: "correct horse"})
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Request.Header.Set("User-Agent", "ua")
ctx.Set(model.CtxKeyRealIPStr, "1.2.3.4")
data, err := authenticator()(ctx)
require.NoError(t, err)
claims, ok := data.(map[string]interface{})
require.True(t, ok, "authenticator must return claims map")
keyID, _ := claims[jwtClaimKeyID].(string)
require.NotEmpty(t, keyID)
var sess model.JWTSession
require.NoError(t, singleton.DB.First(&sess, "key_id = ?", keyID).Error)
assert.Equal(t, uint64(7), sess.TokenVersion,
"session must record the user's current token_version, otherwise identityHandler will reject the freshly-issued token")
verify := newCtxForUser(0, "1.2.3.4", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: claims[jwtClaimUserID],
jwtClaimKeyID: claims[jwtClaimKeyID],
})
assert.NotNil(t, identityHandler()(verify),
"the very next request with the freshly-issued token must authenticate")
}
func TestAuthenticator_BadPasswordReturnsFailedAuth(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
pw, err := bcrypt.GenerateFromPassword([]byte("correct horse"), bcrypt.MinCost)
require.NoError(t, err)
require.NoError(t, singleton.DB.Model(&model.User{}).
Where("id = ?", 100).
Update("password", string(pw)).Error)
ctx := newCtxForUser(0, "1.2.3.4", "ua")
body, _ := json.Marshal(model.LoginRequest{Username: "victim", Password: "wrong"})
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Request.Header.Set("User-Agent", "ua")
ctx.Set(model.CtxKeyRealIPStr, "1.2.3.4")
_, err = authenticator()(ctx)
require.Error(t, err, "wrong password must fail authentication")
require.Equal(t, jwt.ErrFailedAuthentication, err)
var w model.WAF
require.NoError(t, singleton.DB.Where("block_identifier = ?", int64(100)).First(&w).Error,
"bad password must increment WAF counter under user-specific BlockID")
require.GreaterOrEqual(t, w.Count, uint64(1))
}
func TestAuthenticator_UnknownUserReturnsFailedAuth(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
body, _ := json.Marshal(model.LoginRequest{Username: "ghost", Password: "anything"})
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Set(model.CtxKeyRealIPStr, "1.2.3.4")
_, err := authenticator()(ctx)
require.Error(t, err)
require.Equal(t, jwt.ErrFailedAuthentication, err)
var w model.WAF
require.NoError(t, singleton.DB.Where("block_identifier = ?", int64(model.BlockIDUnknownUser)).First(&w).Error,
"unknown user must increment WAF counter under BlockIDUnknownUser")
}
func TestAuthenticator_RejectPasswordUserRefused(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
pw, err := bcrypt.GenerateFromPassword([]byte("ok"), bcrypt.MinCost)
require.NoError(t, err)
require.NoError(t, singleton.DB.Model(&model.User{}).
Where("id = ?", 100).
Updates(map[string]any{"password": string(pw), "reject_password": true}).Error)
ctx := newCtxForUser(0, "1.2.3.4", "ua")
body, _ := json.Marshal(model.LoginRequest{Username: "victim", Password: "ok"})
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Set(model.CtxKeyRealIPStr, "1.2.3.4")
_, err = authenticator()(ctx)
require.Equal(t, jwt.ErrFailedAuthentication, err,
"users with reject_password=true must not be able to log in via password even with correct one")
}
func TestIdentityHandler_ExpiredSessionRejected(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
keyID := claims[jwtClaimKeyID].(string)
require.NoError(t, singleton.DB.Model(&model.JWTSession{}).
Where("key_id = ?", keyID).
Update("expires_at", time.Now().Add(-time.Hour)).Error)
verify := newCtxForUser(0, "1.2.3.4", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: claims[jwtClaimUserID],
jwtClaimKeyID: claims[jwtClaimKeyID],
})
identity := identityHandler()(verify)
require.Nil(t, identity, "session whose expires_at is in the past must reject")
}
func TestRefreshResponse_UpdatesSessionExpires(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
keyID := claims[jwtClaimKeyID].(string)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/refresh-token", nil)
c.Set(jwtClaimKeyID, keyID)
newExpire := time.Now().Add(2 * time.Hour).Truncate(time.Second)
refreshResponse(c, 200, "fake-token", newExpire)
var sess model.JWTSession
require.NoError(t, singleton.DB.First(&sess, "key_id = ?", keyID).Error)
require.WithinDuration(t, newExpire, sess.ExpiresAt, time.Second,
"refreshResponse must extend the session's expires_at to the new expiry")
require.WithinDuration(t, time.Now(), sess.LastUsedAt, 5*time.Second,
"refreshResponse must touch last_used_at")
}
+202
View File
@@ -0,0 +1,202 @@
package controller
import (
"net/http/httptest"
"testing"
"time"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPayloadFunc(t *testing.T) {
payloadFn := payloadFunc()
// 测试包含IP的格式
t.Run("format with IP", func(t *testing.T) {
data := map[string]interface{}{
"user_id": "123",
"ip": "192.168.1.1",
}
claims := payloadFn(data)
assert.Equal(t, "123", claims["user_id"])
assert.Equal(t, "192.168.1.1", claims["ip"])
})
// 测试不包含IP的格式
t.Run("format without IP", func(t *testing.T) {
data := map[string]interface{}{
"user_id": "123",
}
claims := payloadFn(data)
assert.Equal(t, "123", claims["user_id"])
assert.Nil(t, claims["ip"])
})
// 测试无效数据格式
t.Run("invalid data format", func(t *testing.T) {
claims := payloadFn("123") // 字符串类型不再支持
assert.Empty(t, claims)
})
// 测试空的map
t.Run("empty map", func(t *testing.T) {
data := map[string]interface{}{}
claims := payloadFn(data)
assert.Empty(t, claims)
})
}
func TestIPBinding(t *testing.T) {
// 创建测试用的gin context
gin.SetMode(gin.TestMode)
t.Run("IP mismatch should invalidate token", func(t *testing.T) {
// 模拟JWT claims包含IP绑定
claims := jwt.MapClaims{
"user_id": "123",
"ip": "192.168.1.1",
"exp": float64(time.Now().Add(time.Hour).Unix()),
}
// 这里需要实际的数据库和用户设置来完全测试
// 但可以测试claims的基本结构
assert.Equal(t, "123", claims["user_id"])
assert.Equal(t, "192.168.1.1", claims["ip"])
})
t.Run("no IP in token should deny access", func(t *testing.T) {
// 没有IP绑定的token应该被拒绝
claims := jwt.MapClaims{
"user_id": "123",
"exp": float64(time.Now().Add(time.Hour).Unix()),
}
// 验证token结构
assert.Equal(t, "123", claims["user_id"])
assert.Nil(t, claims["ip"])
})
}
func TestValidateRuleRejectsForeignTriggerTasks(t *testing.T) {
ctx := newMemberValidationContext(t)
alertRule := &model.AlertRule{
Common: model.Common{UserID: 200},
Name: "member alert",
Rules: []*model.Rule{{Type: "offline", Duration: 3}},
FailTriggerTasks: []uint64{42},
RecoverTriggerTasks: []uint64{42},
}
assert.Error(t, validateRule(ctx, alertRule))
}
func TestValidateServersRejectsForeignTriggerTasks(t *testing.T) {
ctx := newMemberValidationContext(t)
service := &model.Service{
Common: model.Common{UserID: 200},
Name: "member service",
EnableTriggerTask: true,
FailTriggerTasks: []uint64{42},
RecoverTriggerTasks: []uint64{42},
SkipServers: map[uint64]bool{},
}
assert.Error(t, validateServers(ctx, service))
}
func newMemberValidationContext(t *testing.T) *gin.Context {
t.Helper()
return newValidationContext(t, 200, model.RoleMember)
}
func newAdminValidationContext(t *testing.T) *gin.Context {
t.Helper()
return newValidationContext(t, 1, model.RoleAdmin)
}
func newValidationContext(t *testing.T, userID uint64, role model.Role) *gin.Context {
t.Helper()
originalDB := singleton.DB
originalLoc := singleton.Loc
originalLocalizer := singleton.Localizer
originalCronShared := singleton.CronShared
originalServerShared := singleton.ServerShared
originalUserInfo := singleton.UserInfoMap
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
sqlDB, err := db.DB()
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(
&model.Cron{},
&model.Server{},
&model.NotificationGroup{},
&model.NotificationGroupNotification{},
&model.ServerGroup{},
&model.ServerGroupServer{},
))
assert.NoError(t, db.Create(&model.Cron{
Common: model.Common{ID: 42, UserID: 1},
Name: "foreign trigger task",
Command: "admin-maintenance",
TaskType: model.CronTypeTriggerTask,
Cover: model.CronCoverAlertTrigger,
}).Error)
assert.NoError(t, db.Create(&model.Cron{
Common: model.Common{ID: 43, UserID: 200},
Name: "member trigger task",
Command: "member-task",
TaskType: model.CronTypeTriggerTask,
Cover: model.CronCoverAlertTrigger,
}).Error)
assert.NoError(t, db.Create(&model.NotificationGroup{
Common: model.Common{ID: 7, UserID: 1},
Name: "admin group",
}).Error)
assert.NoError(t, db.Create(&model.NotificationGroup{
Common: model.Common{ID: 8, UserID: 200},
Name: "member group",
}).Error)
singleton.DB = db
singleton.Loc = time.Local
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
singleton.ServerShared = singleton.NewServerClass()
singleton.CronShared = singleton.NewCronClass()
singleton.UserLock.Lock()
singleton.UserInfoMap = map[uint64]model.UserInfo{
1: {Role: model.RoleAdmin},
200: {Role: model.RoleMember},
}
singleton.UserLock.Unlock()
t.Cleanup(func() {
singleton.CronShared.Close()
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Loc = originalLoc
singleton.Localizer = originalLocalizer
singleton.CronShared = originalCronShared
singleton.ServerShared = originalServerShared
singleton.UserLock.Lock()
singleton.UserInfoMap = originalUserInfo
singleton.UserLock.Unlock()
})
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(model.CtxKeyAuthorizedUser, &model.User{
Common: model.Common{ID: userID},
Role: role,
})
return ctx
}
+505
View File
@@ -0,0 +1,505 @@
// Package controller — MCP (Model Context Protocol) server.
//
// 落地约束:
// - 仅支持 Streamable HTTP transport 的 POST 半边(请求-响应、无 SSE)。
// 首版面向 LLM 工具调用,不需要 server→client 主动推送。后续要做 GET SSE
// 长连接(resource subscription)时再补;客户端兼容 fallback 到普通 POST。
// - JSON-RPC 2.0 编解码内嵌于本文件,未引入第三方 MCP SDK:MCP 协议表面足够小
// (initialize / tools/list / tools/call),自实现可控、零额外依赖。
// - 双层鉴权:闸 1(用户对 server 的所有权)由各 tool handler 调
// singleton.ServerShared.Get + Server.HasPermission;闸 2(PAT scope)由
// mcpTool.RequiredScope 在 dispatch 之前过滤。
package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
// --- JSON-RPC 2.0 wire types ---
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
const (
// JSON-RPC 标准错误码
rpcErrParse = -32700
rpcErrInvalidRequest = -32600
rpcErrMethodNotFound = -32601
rpcErrInvalidParams = -32602
rpcErrInternal = -32603
// MCP 自定义错误码(>= -32000 高位段)
rpcErrUnauthorized = -32001
rpcErrForbidden = -32002
)
// mcpJSONRPCMaxBodyBytes caps the JSON-RPC envelope size at the dashboard
// edge. Real fs.write base64 content goes through fs.transfer (capped
// separately by model.MCPFsTransferMaxSize) so tools/call params here are
// always small. The cap is intentionally generous (8 MiB) to allow
// per-request batched arguments while making OOM-via-decode impossible.
const mcpJSONRPCMaxBodyBytes = 8 * 1024 * 1024
// --- MCP types ---
// mcpServerInfo MCP initialize 响应的 serverInfo 字段。
type mcpServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
type mcpInitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities"`
ServerInfo mcpServerInfo `json:"serverInfo"`
}
// mcpToolDescriptor 是 tools/list 返回的单条 tool 描述。
type mcpToolDescriptor struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
OutputSchema map[string]any `json:"outputSchema,omitempty"`
}
// mcpToolsListResult tools/list 响应。
type mcpToolsListResult struct {
Tools []mcpToolDescriptor `json:"tools"`
}
// mcpContent 是 tools/call 响应里 content[] 的元素。
// 仅实现 text 类型;嵌入对象的结构化数据放在外层 structuredContent。
type mcpContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
// mcpToolCallResult tools/call 响应。
type mcpToolCallResult struct {
Content []mcpContent `json:"content"`
StructuredContent any `json:"structuredContent,omitempty"`
IsError bool `json:"isError,omitempty"`
}
// --- tool 注册框架 ---
// mcpToolHandler 实际业务逻辑:拿到 raw params + gin ctx,返回任意可序列化结构。
type mcpToolHandler func(c *gin.Context, params json.RawMessage) (any, error)
// mcpTool 是注册表里的单元:声明 + scope 要求 + 处理函数。
type mcpTool struct {
Name string
Description string
InputSchema map[string]any
OutputSchema map[string]any // 可选;声明 structuredContent 形状,供严格客户端校验
RequiredScope string // 闸 2 入口;空字符串 = 任意 PAT 都能调(如 meta.whoami)
Handler mcpToolHandler
}
var (
mcpToolsMu sync.RWMutex
mcpTools = map[string]*mcpTool{}
)
// registerMCPTool 把一个 tool 加进全局注册表。建议各 tool 文件在 init() 里调用。
func registerMCPTool(t *mcpTool) {
if t == nil || t.Name == "" || t.Handler == nil {
panic("registerMCPTool: invalid tool")
}
mcpToolsMu.Lock()
defer mcpToolsMu.Unlock()
if _, dup := mcpTools[t.Name]; dup {
panic("registerMCPTool: duplicate name " + t.Name)
}
mcpTools[t.Name] = t
}
// listRegisteredMCPTools 拷贝一份当前注册表(按名字稳定排序逻辑放在调用方)。
func listRegisteredMCPTools() []*mcpTool {
mcpToolsMu.RLock()
defer mcpToolsMu.RUnlock()
out := make([]*mcpTool, 0, len(mcpTools))
for _, t := range mcpTools {
out = append(out, t)
}
return out
}
// --- 入口 handler ---
// mcpEndpoint 处理 POST /mcp。
// 鉴权:上游 apiTokenAuthMiddleware 已经把 PAT 解析到 CtxKeyAuthorizedUser,
// 此处只要确认有 PAT 即可(不接受裸 JWT,避免浏览器误触)。
func mcpEndpoint(c *gin.Context) {
if singleton.Conf == nil || !singleton.Conf.MCPEnabled() {
writeJSONRPCError(c, nil, rpcErrForbidden, "MCP is disabled by the dashboard administrator")
return
}
tok := APITokenFromContext(c)
if tok == nil {
// 同时返回 HTTP 401 + JSON-RPC error:标准 MCP HTTP client 依赖
// HTTP 401 触发 auth 重试/OAuth discovery;JSON-RPC body 保留旧字段
// 不打破 ScopeDenied 类内部断言。
writeJSONRPCErrorWithStatus(c, nil, rpcErrUnauthorized, "missing or invalid API token", http.StatusUnauthorized)
return
}
// MaxBytesReader 必须夹在 PAT 校验通过后、ShouldBindJSON 之前——
// 校验前限流可能让攻击者用伪造 token 触发 audit;校验后限流既挡住合法
// PAT 的 OOM,又不会让匿名请求走到 audit 路径。
if c.Request != nil && c.Request.Body != nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, mcpJSONRPCMaxBodyBytes)
}
// Consume the per-token budget before validating the request so malformed
// envelopes and malformed tools/call params cannot flood the dashboard
// without counting against the limiter. The outcome is applied after the
// method is known so tools/call still surfaces the rate limit as a tool
// error rather than a transport-level error.
rateLimited := !mcpRateLimiterShared.Allow(tok.ID)
var req jsonRPCRequest
if err := c.ShouldBindJSON(&req); err != nil {
if errors.Is(err, errors.New("http: request body too large")) || strings.Contains(err.Error(), "http: request body too large") {
writeJSONRPCErrorWithStatus(c, nil, rpcErrInvalidRequest, "request body exceeds MCP envelope size limit", http.StatusRequestEntityTooLarge)
return
}
// 限流优先:method 无从得知时,over-budget 请求即便 body 畸形也必须
// 走 429,否则攻击者能用畸形 body 在不计入限额的情况下持续刷 parse error。
if rateLimited {
writeJSONRPCErrorWithStatus(c, nil, rpcErrForbidden, "rate limit exceeded for this token", http.StatusTooManyRequests)
return
}
writeJSONRPCError(c, nil, rpcErrParse, "invalid json-rpc envelope: "+err.Error())
return
}
if req.JSONRPC != "2.0" || req.Method == "" {
if rateLimited {
writeJSONRPCErrorWithStatus(c, req.ID, rpcErrForbidden, "rate limit exceeded for this token", http.StatusTooManyRequests)
return
}
writeJSONRPCError(c, req.ID, rpcErrInvalidRequest, "invalid json-rpc envelope")
return
}
if rateLimited {
if req.Method == "tools/call" {
writeToolCallError(c, req.ID, model.MCPOutcomeRateLimited, "rate limit exceeded for this token")
return
}
writeJSONRPCErrorWithStatus(c, req.ID, rpcErrForbidden, "rate limit exceeded for this token", http.StatusTooManyRequests)
return
}
switch req.Method {
case "initialize":
writeJSONRPCResult(c, req.ID, mcpInitializeResult{
ProtocolVersion: "2024-11-05",
Capabilities: map[string]any{
"tools": map[string]any{"listChanged": false},
},
ServerInfo: mcpServerInfo{
Name: "nezha-mcp",
Version: singleton.Version,
},
})
case "notifications/initialized", "ping":
// 客户端通知或心跳;JSON-RPC 通知没有 id,但 ping 有 id 时返回空 result
if len(req.ID) > 0 && string(req.ID) != "null" {
writeJSONRPCResult(c, req.ID, struct{}{})
return
}
c.Status(http.StatusAccepted)
case "tools/list":
writeJSONRPCResult(c, req.ID, mcpToolsListResult{
Tools: buildToolDescriptors(),
})
case "tools/call":
handleToolsCall(c, &req, tok)
default:
writeJSONRPCError(c, req.ID, rpcErrMethodNotFound, "method not supported: "+req.Method)
}
}
func buildToolDescriptors() []mcpToolDescriptor {
tools := listRegisteredMCPTools()
out := make([]mcpToolDescriptor, 0, len(tools))
for _, t := range tools {
out = append(out, mcpToolDescriptor{
Name: t.Name,
Description: t.Description,
InputSchema: t.InputSchema,
OutputSchema: t.OutputSchema,
})
}
return out
}
// toolCallParams 是 tools/call 的 params 结构。
type toolCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments,omitempty"`
}
func handleToolsCall(c *gin.Context, req *jsonRPCRequest, tok *model.APIToken) {
var p toolCallParams
if len(req.Params) > 0 {
if err := json.Unmarshal(req.Params, &p); err != nil {
writeJSONRPCError(c, req.ID, rpcErrInvalidParams, "invalid arguments: "+err.Error())
return
}
}
if p.Name == "" {
writeJSONRPCError(c, req.ID, rpcErrInvalidParams, "tool name required")
return
}
mcpToolsMu.RLock()
tool, ok := mcpTools[p.Name]
mcpToolsMu.RUnlock()
if !ok {
writeJSONRPCError(c, req.ID, rpcErrMethodNotFound, "unknown tool: "+p.Name)
return
}
uid := uint64(0)
if u, ok := c.Get(model.CtxKeyAuthorizedUser); ok {
if user, ok := u.(*model.User); ok && user != nil {
uid = user.ID
}
}
startedAt := time.Now()
audit := model.MCPAuditLog{
UserID: uid,
TokenID: tok.ID,
Tool: p.Name,
IP: c.GetString(model.CtxKeyRealIPStr),
}
finish := func(outcome, errCode, errMsg string, result any) {
if outcome == model.MCPOutcomeOK {
textPayload, err := marshalMCPToolResult(result)
if err != nil {
outcome = model.MCPOutcomeAgentError
errCode = model.MCPOutcomeAgentError
errMsg = "failed to encode tool result: " + err.Error()
result = nil
} else {
writeJSONRPCResult(c, req.ID, mcpToolCallResult{
Content: []mcpContent{{Type: "text", Text: textPayload}},
StructuredContent: result,
})
}
}
audit.Outcome = outcome
audit.ErrorCode = errCode
audit.ErrorMsg = truncateString(errMsg, 512)
audit.DurationMs = time.Since(startedAt).Milliseconds()
audit.ServerID = extractServerID(p.Arguments)
mcpAuditWrite(audit, p.Arguments)
if outcome == model.MCPOutcomeOK {
return
}
// Semantic tool failures may retain a typed structured result (notably
// server.exec) so clients can distinguish a non-zero command outcome from
// transport, authorization, or deadline failures.
writeJSONRPCResult(c, req.ID, mcpToolCallResult{
Content: []mcpContent{{Type: "text", Text: errMsg}},
StructuredContent: result,
IsError: true,
})
}
if tool.RequiredScope != "" && !tok.HasScope(tool.RequiredScope) {
finish(model.MCPOutcomeScopeDenied, model.MCPOutcomeScopeDenied,
"missing required scope: "+tool.RequiredScope, nil)
return
}
// 让 PAT 吊销能立即中断进行中的 tools/call(如 server.exec 最长 ~305s):
// 派生一个可取消 ctx 注入 c.Request,下游 CallAgent 用 c.Request.Context()
// 即会观察到取消;cancel 注册进吊销表,deleteAPIToken 会立刻触发它。
if c.Request != nil {
callCtx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
deregister := registerPATConnection(c, cancel)
defer deregister()
c.Request = c.Request.WithContext(callCtx)
}
result, err := tool.Handler(c, p.Arguments)
if err != nil {
code, msg := classifyToolError(err)
var structuredErr interface{ StructuredResult() any }
if errors.As(err, &structuredErr) {
finish(code, code, msg, structuredErr.StructuredResult())
return
}
finish(code, code, msg, nil)
return
}
finish(model.MCPOutcomeOK, "", "", result)
}
// classifyToolError 把任何 handler 返回的 error 归类成审计 outcome + 安全错误消息。
// 优先匹配 mcpError 自带的 Code;否则匹配已知的 rpc.ErrAgent* 类型,最后回退 internal。
func classifyToolError(err error) (code, msg string) {
if me, ok := err.(*mcpError); ok {
return me.Code, me.Msg
}
if errors.Is(err, rpc.ErrAgentOffline) {
return model.MCPOutcomeServerOffline, "agent offline"
}
if errors.Is(err, rpc.ErrAgentTimeout) {
return model.MCPOutcomeAgentTimeout, "agent did not respond within timeout"
}
if errors.Is(err, rpc.ErrMCPDisabled) {
// kill switch 触发的中断必须独立成 outcome,避免审计/SIEM 把
// “管理员关了 MCP”误报成 agent 故障;错误文本透传原始原因。
return model.MCPOutcomeMCPDisabled, err.Error()
}
return model.MCPOutcomeAgentError, err.Error()
}
// extractServerID 从 raw arguments JSON 里提取 server_id(best-effort,只用于审计字段)。
func extractServerID(raw json.RawMessage) uint64 {
if len(raw) == 0 {
return 0
}
var probe struct {
ServerID uint64 `json:"server_id"`
}
_ = json.Unmarshal(raw, &probe)
return probe.ServerID
}
func truncateString(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
// --- wire writers ---
func writeJSONRPCResult(c *gin.Context, id json.RawMessage, result any) {
c.JSON(http.StatusOK, jsonRPCResponse{
JSONRPC: "2.0",
ID: id,
Result: result,
})
}
func writeJSONRPCError(c *gin.Context, id json.RawMessage, code int, message string) {
writeJSONRPCErrorWithStatus(c, id, code, message, http.StatusOK)
}
func writeToolCallError(c *gin.Context, id json.RawMessage, errCode, errMsg string) {
writeJSONRPCResult(c, id, mcpToolCallResult{
Content: []mcpContent{{Type: "text", Text: errMsg}},
IsError: true,
StructuredContent: map[string]string{
"error_code": errCode,
"error": errMsg,
},
})
}
func writeJSONRPCErrorWithStatus(c *gin.Context, id json.RawMessage, code int, message string, status int) {
c.JSON(status, jsonRPCResponse{
JSONRPC: "2.0",
ID: id,
Error: &jsonRPCError{Code: code, Message: message},
})
}
// --- 错误语义 ---
// mcpError 是 tool handler 可以返回的语义化错误。
// dispatch 根据 Code 决定 audit outcome 与 JSON-RPC 错误码(如果命中 rpcErr* 域)。
type mcpError struct {
Code string
Msg string
}
func (e *mcpError) Error() string { return e.Msg }
func newMCPError(code, msg string) *mcpError { return &mcpError{Code: code, Msg: msg} }
// 预制错误
var (
errMCPInvalidArgs = func(s string) *mcpError { return newMCPError(model.MCPOutcomeInvalidArgs, s) }
errMCPPermDenied = newMCPError(model.MCPOutcomePermDenied, "permission denied")
errMCPScopeDenied = func(s string) *mcpError {
return newMCPError(model.MCPOutcomeScopeDenied, "missing required scope: "+s)
}
errMCPServerOffline = newMCPError(model.MCPOutcomeServerOffline, "agent offline")
errMCPAgentTimeout = newMCPError(model.MCPOutcomeAgentTimeout, "agent did not respond within timeout")
errMCPUnsupported = newMCPError(model.MCPOutcomeUnsupportedAgent, "agent does not support this MCP capability; please upgrade the agent")
)
// --- 共用工具 ---
var errNoToken = errors.New("no api token in context")
// decodeToolArgs 是 tool handler 用来反序列化 arguments 的辅助。
func decodeToolArgs(raw json.RawMessage, out any) error {
if len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("invalid arguments: %w", err)
}
return nil
}
// requireServerAccess 是 tool handler 共用的「闸 1 + 闸 2 服务器白名单」组合校验。
// 通过返回 *model.Server;失败返回带语义 Code 的 mcpError,便于 dispatch 归类审计。
func requireServerAccess(c *gin.Context, serverID uint64) (*model.Server, error) {
if serverID == 0 {
return nil, errMCPInvalidArgs("server_id required")
}
tok := APITokenFromContext(c)
if tok != nil && !tok.CanAccessServer(serverID) {
return nil, errMCPPermDenied
}
server, _ := singleton.ServerShared.Get(serverID)
if server == nil {
return nil, errMCPServerOffline
}
if !server.HasPermission(c) {
return nil, errMCPPermDenied
}
return server, nil
}
+48
View File
@@ -0,0 +1,48 @@
package controller
import (
"crypto/sha256"
"encoding/hex"
"log"
"time"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// mcpAuditWrite 异步写一条 MCP 审计日志。失败仅 log,不阻塞业务。
//
// argsBytes:tool 的 raw JSON 参数(dispatcher 已经反序列化过)。
// 只记录 sha256 全文哈希,不保留任何明文片段:server.exec 的 env/stdin、
// fs.write 的 content 等字段会包含 token、密码、密钥、文件内容等敏感数据,
// 任何长度的 peek 都可能让审计表本身成为 secret 仓库;以哈希做关联即可。
//
// 测试可以把 mcpAuditSync 置为 true 让写入同步,避免 goroutine 与测试 teardown
// 形成竞态(不同测试 swap 全局 singleton.DB 时尤其明显)。
func mcpAuditWrite(entry model.MCPAuditLog, argsBytes []byte) {
if len(argsBytes) > 0 {
sum := sha256.Sum256(argsBytes)
entry.ArgsHash = hex.EncodeToString(sum[:])
}
entry.ArgsPeek = ""
if entry.CreatedAt.IsZero() {
entry.CreatedAt = time.Now()
}
db := singleton.DB
write := func(e model.MCPAuditLog) {
if db == nil {
return
}
if err := db.Create(&e).Error; err != nil {
log.Printf("NEZHA>> mcp audit write failed: %v", err)
}
}
if mcpAuditSync {
write(entry)
return
}
go write(entry)
}
// mcpAuditSync 仅供测试切换为同步写入,生产保持 false。
var mcpAuditSync = false
@@ -0,0 +1,72 @@
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// H7 regression: the MCP endpoint must cap incoming JSON-RPC body size
// BEFORE decoding. Without this, a valid PAT can post a multi-GB body and
// the dashboard exhausts memory in ShouldBindJSON. We assert the body
// reader is wrapped in http.MaxBytesReader; the exact error path the
// decoder takes is irrelevant as long as the cap is enforced.
func TestMCPEndpoint_BodyIsCappedByMaxBytesReader(t *testing.T) {
prevConf := singleton.Conf
cfg := &model.Config{}
cfg.SetMCPEnabled(true)
singleton.Conf = &singleton.ConfigClass{Config: cfg}
t.Cleanup(func() { singleton.Conf = prevConf })
tok := &model.APIToken{ID: 1, ScopesCSV: "nezha:server:read"}
// 16 MiB of valid-JSON whitespace prefix forces the decoder to actually
// stream past the limit, exercising MaxBytesReader.
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":"` +
strings.Repeat("x", mcpJSONRPCMaxBodyBytes+1024) + `"}`
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Set(apiTokenCtxKey, tok)
c.Set(model.CtxKeyAPIToken, tok)
mcpEndpoint(c)
if !strings.Contains(w.Body.String(), "request body") &&
w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized body must be rejected with a body-size error, got code=%d body=%s",
w.Code, w.Body.String())
}
}
func TestMCPEndpoint_AcceptsSmallBody(t *testing.T) {
prevConf := singleton.Conf
cfg := &model.Config{}
cfg.SetMCPEnabled(true)
singleton.Conf = &singleton.ConfigClass{Config: cfg}
t.Cleanup(func() { singleton.Conf = prevConf })
tok := &model.APIToken{ID: 1, ScopesCSV: "nezha:server:read"}
body := `{"jsonrpc":"2.0","id":1,"method":"initialize"}`
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Set(apiTokenCtxKey, tok)
c.Set(model.CtxKeyAPIToken, tok)
mcpEndpoint(c)
if w.Code != http.StatusOK {
t.Fatalf("small valid body must succeed, got code=%d body=%s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,71 @@
package controller
import (
"strings"
"github.com/nezhahq/nezha/model"
)
// MCPMinAgentVersion 是支持 MCP 的最低 agent 版本。
//
// release 流程:在 agent ship 了 MCP handlers 后,把此值更新为该 release 的版本号。
// 不变量:必须为非空。否则旧 agent 收到 TaskTypeExec/TaskTypeFs* 等新任务类型时
// 走 default 分支不回 TaskResult,dashboard 要等 CallAgent 超时(30s)甚至更久
// (fs.transfer 的 IOStream attach 30s)才能感知,这是 server-transfer 已经
// 通过 MinServerTransferAgentVersion 修复过的同类问题。
const MCPMinAgentVersion = "v2.1.0"
// requireAgentSupportsMCP 在 tool handler 调 CallAgent 之前快速失败不支持的 agent。
// 仅作为 UX 优化:真正的安全/正确性由 agent 端 task switch 的 default 分支保障。
func requireAgentSupportsMCP(server *model.Server) error {
if MCPMinAgentVersion == "" || server == nil {
return nil
}
runtime := server.RuntimeSnapshot()
if runtime.Host == nil {
return nil
}
if compareSemver(runtime.Host.Version, MCPMinAgentVersion) < 0 {
return errMCPUnsupported
}
return nil
}
// compareSemver 比较两个 "MAJOR.MINOR.PATCH[-suffix]" 字符串,返回 -1/0/1。
// semverParts 已剥掉可选的 "v" 前缀与 "-/+" 后缀,所以只按三段数字定序:
// 数字段相等即视为相等版本。绝不能回退到字符串字典序——agent 上报 "2.1.0"
// 而门槛常量是 "v2.1.0",'2'(0x32) < 'v'(0x76) 会把相等版本误判为更旧,
// 导致所有 agent 被错误地判为不支持 MCP。
func compareSemver(a, b string) int {
aparts := semverParts(a)
bparts := semverParts(b)
for i := 0; i < 3; i++ {
if aparts[i] < bparts[i] {
return -1
}
if aparts[i] > bparts[i] {
return 1
}
}
return 0
}
func semverParts(v string) [3]int {
v = strings.TrimPrefix(v, "v")
if i := strings.IndexAny(v, "-+"); i >= 0 {
v = v[:i]
}
var out [3]int
parts := strings.Split(v, ".")
for i := 0; i < 3 && i < len(parts); i++ {
n := 0
for _, c := range parts[i] {
if c < '0' || c > '9' {
break
}
n = n*10 + int(c-'0')
}
out[i] = n
}
return out
}
@@ -0,0 +1,77 @@
package controller
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
// 旧 agent 不识别 TaskTypeExec/TaskTypeFs* 等新 task type,会走 default
// 分支不回 TaskResult;dashboard 必须在调 CallAgent 之前依据 Host.Version
// 快速失败,否则用户要等到 30s/24h timeout 才知道 agent 不支持。
// MinServerTransferAgentVersion 已为同类问题在 transfer 路径上确立了
// release-time 必填的版本下限——这里把 MCP 也纳入同一不变量。
func TestMCPMinAgentVersionIsPinnedToRelease(t *testing.T) {
require.NotEmpty(t, MCPMinAgentVersion,
"MCPMinAgentVersion must be set to the lowest agent build that ships MCP handlers; an empty string disables the gate and lets old agents hang dashboard requests until timeout")
}
func TestRequireAgentSupportsMCPRejectsBelowMinVersion(t *testing.T) {
old := &model.Server{Host: &model.Host{Version: "v0.0.1"}}
err := requireAgentSupportsMCP(old)
require.Error(t, err, "agents older than MCPMinAgentVersion must be rejected before CallAgent")
require.True(t, errors.Is(err, errMCPUnsupported) || err.Error() == errMCPUnsupported.Error(),
"expected the errMCPUnsupported sentinel, got %v", err)
}
func TestRequireAgentSupportsMCPAcceptsCurrentVersion(t *testing.T) {
current := &model.Server{Host: &model.Host{Version: MCPMinAgentVersion}}
require.NoError(t, requireAgentSupportsMCP(current),
"server reporting exactly MCPMinAgentVersion must be accepted")
}
// 钉住「最近一个不带 MCP handler 的已发布 agent tag (v2.0.4) 必须被拒绝」。
// v2.0.4 的 model/task.go 还没有 TaskTypeExec/TaskTypeFs* 常量,cmd/agent/
// mcp_handlers.go 也不存在;如果版本门槛把它放行,dashboard 调 MCP tool
// 后 agent 会走 default 分支不回 TaskResult,CallAgent 必须等 30s 超时。
func TestRequireAgentSupportsMCPRejectsLastReleaseWithoutMCP(t *testing.T) {
noMCP := &model.Server{Host: &model.Host{Version: "v2.0.4"}}
err := requireAgentSupportsMCP(noMCP)
require.Error(t, err,
"v2.0.4 is the latest released agent tag that ships *without* MCP handlers; bumping MCPMinAgentVersion below the first MCP release re-introduces the silent-timeout bug")
require.True(t, errors.Is(err, errMCPUnsupported) || err.Error() == errMCPUnsupported.Error(),
"expected the errMCPUnsupported sentinel, got %v", err)
}
func TestRequireAgentSupportsMCPDefersWhenAgentNeverReported(t *testing.T) {
require.NoError(t, requireAgentSupportsMCP(&model.Server{Host: nil}),
"Host==nil means agent never reported its build; defer the version decision to the CallAgent timeout layer")
}
func TestCompareSemverIgnoresVPrefixMismatch(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"2.1.0", "v2.1.0", 0},
{"v2.1.0", "2.1.0", 0},
{"2.1.0", "2.1.0", 0},
{"2.1.0", "v2.1.1", -1},
{"v2.1.2", "2.1.0", 1},
{"2.2.0", "v2.1.9", 1},
}
for _, c := range cases {
require.Equalf(t, c.want, compareSemver(c.a, c.b),
"compareSemver(%q,%q): the only difference is the optional 'v' prefix and/or numeric ordering; "+
"a bare lexical fallback wrongly orders %q < %q", c.a, c.b, c.a, c.b)
}
}
func TestRequireAgentSupportsMCPAcceptsBarePrefixReport(t *testing.T) {
agent := &model.Server{Host: &model.Host{Version: "2.1.0"}}
require.NoError(t, requireAgentSupportsMCP(agent),
"agents report Host.Version without the 'v' prefix (e.g. \"2.1.0\"); it must compare equal to MCPMinAgentVersion \"v2.1.0\" and be accepted")
}
@@ -0,0 +1,28 @@
package controller
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
)
// classifyToolError 必须把 rpc.ErrMCPDisabled 归类成 forbidden 类 outcome,而不是
// 当作 agent_error。
//
// ErrMCPDisabled 是 dashboard 主动按下 kill switch 的语义信号(见
// service/rpc/mcp_rpc.go 注释),controller 把它揉进 agent_error 等于把“管理员
// 关了 MCP”和“agent 真出故障”混在一起:审计日志、SIEM 告警和 MCP 客户端的
// structuredContent.error_code 都会错配。
func TestClassifyToolError_MCPDisabledMapsToForbidden(t *testing.T) {
code, msg := classifyToolError(rpc.ErrMCPDisabled)
assert.Equal(t, model.MCPOutcomeMCPDisabled, code,
"rpc.ErrMCPDisabled must map to MCPOutcomeMCPDisabled, not agent_error")
assert.NotEqual(t, model.MCPOutcomeAgentError, code,
"kill-switch errors must not be reported as agent_error in audit/SIEM")
assert.Contains(t, msg, "MCP is disabled",
"error text should preserve the kill switch reason")
}
@@ -0,0 +1,96 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// installTestConfig swaps singleton.Conf with one backed by a tmp file so
// updateConfig's Conf.Save() write-through has a real target. The caller's
// setupMCPTest will restore the original Conf when its cleanup runs.
func installTestConfig(t *testing.T) {
t.Helper()
dir := t.TempDir()
cfg := &model.Config{}
require.NoError(t, cfg.Read(filepath.Join(dir, "config.yaml"), nil))
singleton.Conf = &singleton.ConfigClass{Config: cfg}
}
func TestUpdateConfig_PersistsEnableMCPFlag(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
installTestConfig(t)
origTemplates := singleton.FrontendTemplates
singleton.FrontendTemplates = []model.FrontendTemplate{
{Path: "user-dist", IsAdmin: false},
}
defer func() { singleton.FrontendTemplates = origTemplates }()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
setAuthUser(c, uid, model.RoleAdmin)
c.Next()
})
r.PATCH("/api/v1/setting", commonHandler(updateConfig))
body := map[string]any{
"site_name": "test",
"language": "en_US",
"user_template": "user-dist",
"enable_mcp": true,
}
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/setting", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
require.True(t, success, "PATCH /setting must succeed: %s", errMsg)
require.True(t, singleton.Conf.EnableMCP,
"enable_mcp=true in body must flip singleton.Conf.EnableMCP")
}
func TestMCPEndpoint_RefusesWhenDisabled(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
singleton.Conf.SetMCPEnabled(false)
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "initialize",
})
mcpEndpoint(c)
var env jsonRPCResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &env))
require.NotNil(t, env.Error, "MCP must return JSON-RPC error when disabled; body=%s", w.Body.String())
require.Equal(t, rpcErrForbidden, env.Error.Code,
"disabled MCP must surface as rpcErrForbidden so callers can distinguish from auth failure")
}
func TestMCPEndpoint_AllowsWhenEnabled(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
singleton.Conf.SetMCPEnabled(true)
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "initialize",
})
mcpEndpoint(c)
var env jsonRPCResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &env))
require.Nil(t, env.Error, "MCP must process requests when enabled; got error=%+v", env.Error)
}
@@ -0,0 +1,433 @@
package controller
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata"
"github.com/nezhahq/nezha/model"
pb "github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
type e2eStream struct {
mu sync.Mutex
dispatch func(*pb.Task) *pb.TaskResult
}
func (s *e2eStream) Send(t *pb.Task) error {
// fs.upload_url / fs.download_url 走 IOStream 路径,由独立 mux 处理;
// 这里的 RPC-style dispatch 只覆盖 fs.read/fs.write/fs.list/fs.delete/server.exec。
if t.GetType() == model.TaskTypeFsTransfer {
return e2eHandleFsTransfer(t)
}
s.mu.Lock()
d := s.dispatch
s.mu.Unlock()
if d == nil {
return nil
}
go func(task *pb.Task) {
if res := d(task); res != nil {
rpc.DeliverMCPResultForTest(res)
}
}(t)
return nil
}
// e2eHandleFsTransfer 模拟真实 agent 收到 TaskTypeFsTransfer:把本地文件
// 系统作为后端,按 op 跑完整协议帧并复制字节。和真 agent 不同:
// - 不做 sha256 强校验(测试侧用 NZTO 中的 32 字节固定 0 占位)。
// - 复用 net.Pipe + rpc.NezhaHandlerSingleton.AgentConnected 注入 dashboard 端。
func e2eHandleFsTransfer(t *pb.Task) error {
var req model.FsTransferRequest
if err := json.Unmarshal([]byte(t.GetData()), &req); err != nil {
return err
}
dashboardSide, agentSide := net.Pipe()
if err := rpc.NezhaHandlerSingleton.AgentConnected(req.StreamID, dashboardSide); err != nil {
return err
}
go func() {
defer agentSide.Close()
switch req.Op {
case model.MCPFsTransferOpDownload:
data, err := os.ReadFile(req.Path)
if err != nil {
buf := append([]byte(nil), model.MCPFsXferMagicErr...)
buf = append(buf, err.Error()...)
_, _ = agentSide.Write(buf)
return
}
hdr := append([]byte(nil), model.MCPFsXferMagicDownloadHdr...)
sz := make([]byte, 8)
binary.BigEndian.PutUint64(sz, uint64(len(data)))
hdr = append(hdr, sz...)
hdr = append(hdr, make([]byte, 32)...)
if _, err := agentSide.Write(hdr); err != nil {
return
}
if len(data) > 0 {
chunk := append([]byte(nil), model.MCPFsXferMagicChunk...)
chunkLen := make([]byte, 8)
binary.BigEndian.PutUint64(chunkLen, uint64(len(data)))
chunk = append(chunk, chunkLen...)
chunk = append(chunk, data...)
if _, err := agentSide.Write(chunk); err != nil {
return
}
}
ok := append([]byte(nil), model.MCPFsXferMagicOK...)
ok = append(ok, sz...)
ok = append(ok, make([]byte, 32)...)
_, _ = agentSide.Write(ok)
case model.MCPFsTransferOpUpload:
hdr := append([]byte(nil), model.MCPFsXferMagicUploadHdr...)
sz := make([]byte, 8)
binary.BigEndian.PutUint64(sz, uint64(req.Size))
hdr = append(hdr, sz...)
if _, err := agentSide.Write(hdr); err != nil {
return
}
buf := make([]byte, req.Size)
if req.Size > 0 {
if _, err := io.ReadFull(agentSide, buf); err != nil {
return
}
}
if err := os.WriteFile(req.Path, buf, 0o644); err != nil {
errBuf := append([]byte(nil), model.MCPFsXferMagicErr...)
errBuf = append(errBuf, err.Error()...)
_, _ = agentSide.Write(errBuf)
return
}
ok := append([]byte(nil), model.MCPFsXferMagicOK...)
okSz := make([]byte, 8)
binary.BigEndian.PutUint64(okSz, uint64(len(buf)))
ok = append(ok, okSz...)
ok = append(ok, make([]byte, 32)...)
_, _ = agentSide.Write(ok)
}
}()
return nil
}
func (s *e2eStream) Recv() (*pb.TaskResult, error) { return nil, context.Canceled }
func (s *e2eStream) SetHeader(metadata.MD) error { return nil }
func (s *e2eStream) SendHeader(metadata.MD) error { return nil }
func (s *e2eStream) SetTrailer(metadata.MD) {}
func (s *e2eStream) Context() context.Context { return context.Background() }
func (s *e2eStream) SendMsg(any) error { return nil }
func (s *e2eStream) RecvMsg(any) error { return context.Canceled }
func agentSim(task *pb.Task) *pb.TaskResult {
res := &pb.TaskResult{Id: task.GetId(), Type: task.GetType(), Successful: true}
switch task.GetType() {
case model.TaskTypeFsList:
var req model.FsListRequest
_ = json.Unmarshal([]byte(task.GetData()), &req)
entries, err := os.ReadDir(req.Path)
if err != nil {
b, _ := json.Marshal(model.FsListResult{Error: err.Error()})
res.Data = string(b)
return res
}
out := make([]model.FsEntry, 0, len(entries))
for _, e := range entries {
info, _ := e.Info()
out = append(out, model.FsEntry{Name: e.Name(), Type: "file", Size: info.Size()})
}
b, _ := json.Marshal(model.FsListResult{Entries: out, Total: len(out)})
res.Data = string(b)
case model.TaskTypeFsRead:
var req model.FsReadRequest
_ = json.Unmarshal([]byte(task.GetData()), &req)
data, err := os.ReadFile(req.Path)
if err != nil {
b, _ := json.Marshal(model.FsReadResult{Error: err.Error()})
res.Data = string(b)
return res
}
encoding := req.Encoding
if encoding == "" {
encoding = "utf8"
}
var content string
switch encoding {
case "base64":
content = base64.StdEncoding.EncodeToString(data)
default:
content = string(data)
}
b, _ := json.Marshal(model.FsReadResult{Content: content, Encoding: encoding, Size: int64(len(data))})
res.Data = string(b)
case model.TaskTypeFsWrite:
var req model.FsWriteRequest
_ = json.Unmarshal([]byte(task.GetData()), &req)
data := []byte(req.Content)
if req.Encoding == "base64" {
decoded, decErr := base64.StdEncoding.DecodeString(req.Content)
if decErr != nil {
b, _ := json.Marshal(model.FsWriteResult{Error: decErr.Error()})
res.Data = string(b)
return res
}
data = decoded
}
_ = os.WriteFile(req.Path, data, 0o644)
b, _ := json.Marshal(model.FsWriteResult{Size: int64(len(data))})
res.Data = string(b)
case model.TaskTypeFsDelete:
var req model.FsDeleteRequest
_ = json.Unmarshal([]byte(task.GetData()), &req)
_ = os.RemoveAll(req.Path)
b, _ := json.Marshal(model.FsDeleteResult{DeletedCount: 1})
res.Data = string(b)
case model.TaskTypeExec:
b, _ := json.Marshal(model.ExecResult{ExitCode: 0, Stdout: "simulated"})
res.Data = string(b)
default:
res.Successful = false
res.Data = "unsupported task"
}
return res
}
func setupEndToEnd(t *testing.T) (*httptest.Server, string, func()) {
t.Helper()
cleanupBase, uid := setupMCPTest(t)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
stream := &e2eStream{dispatch: agentSim}
srv, _ := singleton.ServerShared.Get(7)
srv.SetTaskStream(stream)
prevCleanup := cleanupBase
cleanupBase = func() {
rpc.NezhaHandlerSingleton = originalHandler
prevCleanup()
}
_, plain := mkToken(t, uid, []string{
model.ScopeInventoryRead,
model.ScopeInventoryDelete,
model.ScopeServerRead,
model.ScopeServerExec,
model.ScopeServerWrite,
model.ScopeServerDelete,
}, nil)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/mcp", apiTokenAuthMiddleware(), mcpEndpoint)
r.GET("/mcp/download/:token", transferDownloadHandler)
r.POST("/mcp/upload/:token", transferUploadHandler)
ts := httptest.NewServer(r)
return ts, plain, func() {
ts.Close()
cleanupBase()
}
}
func e2eCall(t *testing.T, ts *httptest.Server, token, method, toolName string, args any) map[string]any {
t.Helper()
body := map[string]any{"jsonrpc": "2.0", "id": 1, "method": method}
if method == "tools/call" {
argsRaw, _ := json.Marshal(args)
body["params"] = map[string]any{"name": toolName, "arguments": json.RawMessage(argsRaw)}
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", ts.URL+"/mcp", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
var env map[string]any
require.NoError(t, json.Unmarshal(out, &env))
return env
}
func TestE2E_Initialize(t *testing.T) {
ts, tok, cleanup := setupEndToEnd(t)
defer cleanup()
env := e2eCall(t, ts, tok, "initialize", "", nil)
require.Nil(t, env["error"])
info := env["result"].(map[string]any)["serverInfo"].(map[string]any)
require.Equal(t, "nezha-mcp", info["name"])
}
func TestE2E_ToolsList(t *testing.T) {
ts, tok, cleanup := setupEndToEnd(t)
defer cleanup()
env := e2eCall(t, ts, tok, "tools/list", "", nil)
require.Nil(t, env["error"])
tools := env["result"].(map[string]any)["tools"].([]any)
require.GreaterOrEqual(t, len(tools), 9)
}
func TestE2E_WhoamiAndServerList(t *testing.T) {
ts, tok, cleanup := setupEndToEnd(t)
defer cleanup()
env := e2eCall(t, ts, tok, "tools/call", "meta.whoami", map[string]any{})
res := env["result"].(map[string]any)
require.False(t, res["isError"] == true)
env = e2eCall(t, ts, tok, "tools/call", "server.list", map[string]any{})
res = env["result"].(map[string]any)
require.False(t, res["isError"] == true)
}
func TestE2E_ServerExec(t *testing.T) {
ts, tok, cleanup := setupEndToEnd(t)
defer cleanup()
env := e2eCall(t, ts, tok, "tools/call", "server.exec", map[string]any{
"server_id": 7, "cmd": "echo",
})
res := env["result"].(map[string]any)
require.False(t, res["isError"] == true, "exec failed: %v", res)
struc := res["structuredContent"].(map[string]any)
require.Equal(t, "simulated", struc["stdout"])
}
func TestE2E_FsLifecycle(t *testing.T) {
ts, tok, cleanup := setupEndToEnd(t)
defer cleanup()
dir := t.TempDir()
p := filepath.Join(dir, "e2e.txt")
env := e2eCall(t, ts, tok, "tools/call", "fs.write", map[string]any{
"server_id": 7, "path": p, "content": "ohi", "encoding": "utf8",
})
require.False(t, env["result"].(map[string]any)["isError"] == true)
env = e2eCall(t, ts, tok, "tools/call", "fs.read", map[string]any{"server_id": 7, "path": p})
res := env["result"].(map[string]any)
struc := res["structuredContent"].(map[string]any)
require.Equal(t, "ohi", struc["content"])
env = e2eCall(t, ts, tok, "tools/call", "fs.delete", map[string]any{"server_id": 7, "path": p})
require.False(t, env["result"].(map[string]any)["isError"] == true)
}
func TestE2E_DownloadUploadURL(t *testing.T) {
ts, tok, cleanup := setupEndToEnd(t)
defer cleanup()
dir := t.TempDir()
p := filepath.Join(dir, "blob.txt")
require.NoError(t, os.WriteFile(p, []byte("payload"), 0o644))
env := e2eCall(t, ts, tok, "tools/call", "fs.download_url", map[string]any{
"server_id": 7, "path": p, "ttl_seconds": 60,
})
res := env["result"].(map[string]any)
require.False(t, res["isError"] == true, "download_url failed: %v", res)
url := res["structuredContent"].(map[string]any)["url"].(string)
url = ts.URL + url[strings.Index(url, "/mcp/"):]
resp, err := http.Get(url)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
require.Equal(t, "payload", string(body))
upPath := filepath.Join(dir, "up.txt")
env = e2eCall(t, ts, tok, "tools/call", "fs.upload_url", map[string]any{
"server_id": 7, "path": upPath, "ttl_seconds": 60,
})
res = env["result"].(map[string]any)
require.False(t, res["isError"] == true)
upURL := res["structuredContent"].(map[string]any)["url"].(string)
upURL = ts.URL + upURL[strings.Index(upURL, "/mcp/"):]
upReq, _ := http.NewRequest("POST", upURL, bytes.NewReader([]byte("hello-upload")))
upResp, err := http.DefaultClient.Do(upReq)
require.NoError(t, err)
defer upResp.Body.Close()
require.Equal(t, 200, upResp.StatusCode)
got, _ := os.ReadFile(upPath)
require.Equal(t, "hello-upload", string(got))
}
// TestE2E_DownloadUploadURL_100MiB 走完整 mint→IOStream→relay 路径,验证
// 大文件能跨越旧 4MiB gRPC 上限,并且字节序保持不变。
func TestE2E_DownloadUploadURL_100MiB(t *testing.T) {
ts, tok, cleanup := setupEndToEnd(t)
defer cleanup()
dir := t.TempDir()
src := filepath.Join(dir, "src.bin")
want := make([]byte, model.MCPFsTransferMaxSize)
for i := range want {
want[i] = byte(i % 251)
}
require.NoError(t, os.WriteFile(src, want, 0o644))
env := e2eCall(t, ts, tok, "tools/call", "fs.download_url", map[string]any{
"server_id": 7, "path": src, "ttl_seconds": 60,
})
res := env["result"].(map[string]any)
require.False(t, res["isError"] == true, "download_url failed: %v", res)
url := res["structuredContent"].(map[string]any)["url"].(string)
url = ts.URL + url[strings.Index(url, "/mcp/"):]
resp, err := http.Get(url)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
require.Equal(t, len(want), len(body), "100MiB body length mismatch")
require.True(t, bytes.Equal(want, body), "100MiB body content mismatch")
upPath := filepath.Join(dir, "up.bin")
env = e2eCall(t, ts, tok, "tools/call", "fs.upload_url", map[string]any{
"server_id": 7, "path": upPath, "ttl_seconds": 60,
})
res = env["result"].(map[string]any)
require.False(t, res["isError"] == true, "upload_url failed: %v", res)
upURL := res["structuredContent"].(map[string]any)["url"].(string)
upURL = ts.URL + upURL[strings.Index(upURL, "/mcp/"):]
req, _ := http.NewRequest("POST", upURL, bytes.NewReader(want))
req.ContentLength = int64(len(want))
upResp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer upResp.Body.Close()
require.Equal(t, 200, upResp.StatusCode)
got, _ := os.ReadFile(upPath)
require.Equal(t, len(want), len(got))
require.True(t, bytes.Equal(want, got))
}
func TestE2E_AuditRowsAreWritten(t *testing.T) {
ts, tok, cleanup := setupEndToEnd(t)
defer cleanup()
_ = e2eCall(t, ts, tok, "tools/call", "meta.whoami", map[string]any{})
_ = e2eCall(t, ts, tok, "tools/call", "server.list", map[string]any{})
require.Eventually(t, func() bool {
var cnt int64
_ = singleton.DB.Model(&model.MCPAuditLog{}).Count(&cnt).Error
return cnt >= 2
}, 3*time.Second, 20*time.Millisecond)
}
@@ -0,0 +1,67 @@
package controller
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func TestServerExec_ErrorResult_PreservesStructuredContent(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
srv, _ := singleton.ServerShared.Get(7)
srv.SetTaskStream(&execErrorStream{errMsg: "agent disabled command execution"})
tok, _ := mkToken(t, uid, []string{model.ScopeServerExec}, nil)
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
Params: jsonObj(t, toolCallParams{
Name: "server.exec",
Arguments: jsonRaw(map[string]any{
"server_id": 7,
"cmd": "whoami",
"timeout_seconds": 2,
}),
}),
})
mcpEndpoint(c)
_, tcr := decodeRPC(w)
require.NotNil(t, tcr)
require.True(t, tcr.IsError)
require.Contains(t, tcr.Content[0].Text, "agent disabled command execution",
"error text must carry the real cause")
var result model.ExecResult
structuredJSON, err := json.Marshal(tcr.StructuredContent)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(structuredJSON, &result))
require.Equal(t, -1, result.ExitCode)
require.Equal(t, "agent disabled command execution", result.Error)
}
func TestScopeDenied_OmitsStructuredContent(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
Params: jsonObj(t, toolCallParams{
Name: "server.exec",
Arguments: jsonRaw(map[string]any{"server_id": 7, "cmd": "echo"}),
}),
})
mcpEndpoint(c)
_, tcr := decodeRPC(w)
require.NotNil(t, tcr)
require.True(t, tcr.IsError)
require.Nil(t, tcr.StructuredContent)
}
@@ -0,0 +1,226 @@
package controller
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata"
"github.com/nezhahq/nezha/model"
pb "github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
// killSwitchStream is a minimal RequestTask stream that just records sent
// tasks; it never replies. CallAgent under this stream blocks until the
// kill switch wakes it up, which is exactly the behaviour these tests
// pin down.
type killSwitchStream struct {
sent chan *pb.Task
}
func newKillSwitchStream() *killSwitchStream {
return &killSwitchStream{sent: make(chan *pb.Task, 4)}
}
func (s *killSwitchStream) Send(t *pb.Task) error { s.sent <- t; return nil }
func (s *killSwitchStream) Recv() (*pb.TaskResult, error) { return nil, context.Canceled }
func (s *killSwitchStream) SetHeader(metadata.MD) error { return nil }
func (s *killSwitchStream) SendHeader(metadata.MD) error { return nil }
func (s *killSwitchStream) SetTrailer(metadata.MD) {}
func (s *killSwitchStream) Context() context.Context { return context.Background() }
func (s *killSwitchStream) SendMsg(any) error { return nil }
func (s *killSwitchStream) RecvMsg(any) error { return context.Canceled }
func TestRevalidateTransferEntry_BlocksWhenMCPDisabled(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
singleton.Conf.SetMCPEnabled(false)
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
entry := &transferEntry{
UserID: uid,
TokenID: tok.ID,
ServerID: 7,
Path: "/srv/file",
Direction: transferDirDownload,
ExpiresAt: time.Now().Add(5 * time.Minute),
}
err := revalidateTransferEntry(entry)
require.Error(t, err, "revalidate must reject when EnableMCP=false")
require.Contains(t, err.Error(), "MCP is disabled",
"error message must surface kill switch reason, not look like a transient agent fault")
}
func TestPurgeTransferEntries_DropsMintedTokens(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
for i := 0; i < 3; i++ {
_, err := mintTransferToken(transferEntry{
UserID: uid,
TokenID: tok.ID,
ServerID: 7,
Path: "/srv/blob",
Direction: transferDirDownload,
ExpiresAt: time.Now().Add(5 * time.Minute),
})
require.NoError(t, err)
}
purged := PurgeTransferEntries()
require.GreaterOrEqual(t, purged, 3, "all minted entries must be dropped")
count := 0
transferEntries.Range(func(_, _ any) bool { count++; return true })
require.Equal(t, 0, count, "transferEntries must be empty after purge")
}
func TestRevokeStreamsForPurpose_OnlyTouchesMatchingPurpose(t *testing.T) {
h := rpc.NewNezhaHandler()
h.CreateStreamWithPurpose("legacy-1", 0, 1, rpc.PurposeLegacy)
h.CreateStreamWithPurpose("mcp-1", 0, 1, rpc.PurposeMCPTransfer)
h.CreateStreamWithPurpose("mcp-2", 0, 2, rpc.PurposeMCPTransfer)
revoked := h.RevokeStreamsForPurpose(rpc.PurposeMCPTransfer)
require.Equal(t, 2, revoked, "kill switch must take down both MCP streams")
_, legacyErr := h.GetStream("legacy-1")
require.NoError(t, legacyErr,
"legacy purpose streams (terminal/fm/nat) must NOT be revoked by the MCP kill switch")
_, mcp1Err := h.GetStream("mcp-1")
require.Error(t, mcp1Err, "mcp-1 must be gone after revoke")
_, mcp2Err := h.GetStream("mcp-2")
require.Error(t, mcp2Err, "mcp-2 must be gone after revoke")
}
func TestCancelAllMCPInflight_UnblocksCallAgent(t *testing.T) {
stream := newKillSwitchStream()
original := singleton.ServerShared
sc := singleton.NewEmptyServerClassForTest()
srv := &model.Server{}
srv.ID = 88
srv.SetTaskStream(stream)
sc.InsertForTest(srv)
singleton.ServerShared = sc
t.Cleanup(func() { singleton.ServerShared = original })
done := make(chan error, 1)
go func() {
_, err := rpc.CallAgent(context.Background(), 88, model.TaskTypeExec,
model.ExecRequest{Cmd: "sleep"}, 30*time.Second)
done <- err
}()
select {
case <-stream.sent:
case <-time.After(time.Second):
t.Fatalf("CallAgent never reached stream.Send within 1s")
}
rpc.CancelAllMCPInflight()
select {
case err := <-done:
require.ErrorIs(t, err, rpc.ErrMCPDisabled,
"CallAgent must surface ErrMCPDisabled when kill switch fires; got %v", err)
case <-time.After(2 * time.Second):
t.Fatalf("CallAgent did not return after CancelAllMCPInflight; kill switch is broken")
}
}
func TestUpdateConfig_DisablingMCPInvokesKillSwitch(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
installTestConfig(t)
singleton.Conf.SetMCPEnabled(true)
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
_, err := mintTransferToken(transferEntry{
UserID: uid,
TokenID: tok.ID,
ServerID: 7,
Path: "/srv/blob",
Direction: transferDirDownload,
ExpiresAt: time.Now().Add(5 * time.Minute),
})
require.NoError(t, err)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
rpc.NezhaHandlerSingleton.CreateStreamWithPurpose("mcp-active", 0, 7, rpc.PurposeMCPTransfer)
stream := newKillSwitchStream()
sc := singleton.NewEmptyServerClassForTest()
srv := &model.Server{}
srv.ID = 7
srv.SetTaskStream(stream)
sc.InsertForTest(srv)
originalShared := singleton.ServerShared
singleton.ServerShared = sc
t.Cleanup(func() { singleton.ServerShared = originalShared })
rpcDone := make(chan error, 1)
go func() {
_, err := rpc.CallAgent(context.Background(), 7, model.TaskTypeFsRead,
model.FsReadRequest{Path: "/x"}, 30*time.Second)
rpcDone <- err
}()
select {
case <-stream.sent:
case <-time.After(time.Second):
t.Fatalf("background CallAgent never reached the stream")
}
origTemplates := singleton.FrontendTemplates
singleton.FrontendTemplates = []model.FrontendTemplate{{Path: "user-dist", IsAdmin: false}}
defer func() { singleton.FrontendTemplates = origTemplates }()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
setAuthUser(c, uid, model.RoleAdmin)
c.Next()
})
r.PATCH("/api/v1/setting", commonHandler(updateConfig))
settingBody := map[string]any{
"site_name": "test",
"language": "en_US",
"user_template": "user-dist",
"enable_mcp": false,
}
raw, _ := json.Marshal(settingBody)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/setting", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
require.True(t, success, "PATCH /setting must succeed: %s", errMsg)
require.False(t, singleton.Conf.EnableMCP, "config must reflect kill switch state")
count := 0
transferEntries.Range(func(_, _ any) bool { count++; return true })
require.Equal(t, 0, count, "unconsumed transfer URLs must be purged")
_, streamErr := rpc.NezhaHandlerSingleton.GetStream("mcp-active")
require.Error(t, streamErr, "active MCP IOStream must be revoked")
select {
case err := <-rpcDone:
require.True(t, errors.Is(err, rpc.ErrMCPDisabled),
"in-flight CallAgent must wake up with ErrMCPDisabled, got %v", err)
case <-time.After(2 * time.Second):
t.Fatalf("in-flight CallAgent did not wake up after kill switch")
}
}
@@ -0,0 +1,77 @@
package controller
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// Streamable HTTP 规范(modelcontextprotocol.io /basic/transports)要求:
// 服务端如果不提供 standalone SSE,必须对 GET /mcp 返回 405 Method Not Allowed。
// 现状是 Gin 的 NoRoute fallback 会把 GET /mcp 喂给前端 fallback(HTML/404),
// 真实 MCP 客户端在自动探测 SSE 时会卡住或拿到无效内容。
//
// 这条测试拼出和生产 routers() 一致的 /mcp 三件套,仅断言「非 POST 不返回 HTML」。
type mcpFallbackDist struct{}
func (mcpFallbackDist) Open(string) (fs.File, error) { return nil, fs.ErrNotExist }
func setupMCPMethodRouter(t *testing.T) *gin.Engine {
t.Helper()
originalConf := singleton.Conf
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{
ConfigDashboard: model.ConfigDashboard{
AdminTemplate: "admin-dist",
UserTemplate: "user-dist",
},
}}
t.Cleanup(func() { singleton.Conf = originalConf })
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/mcp", mcpOriginGuard(), apiTokenAuthMiddleware(), mcpEndpoint)
r.GET("/mcp", mcpMethodNotAllowed)
r.DELETE("/mcp", mcpMethodNotAllowed)
r.GET("/mcp/download/:token", mcpOriginGuard(), transferDownloadHandler)
r.POST("/mcp/upload/:token", mcpOriginGuard(), transferUploadHandler)
r.NoRoute(fallbackToFrontend(mcpFallbackDist{}))
return r
}
func TestMCP_GetReturnsMethodNotAllowed(t *testing.T) {
t.Chdir(t.TempDir())
r := setupMCPMethodRouter(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET /mcp must return 405 per Streamable HTTP spec; got %d body=%q",
w.Code, w.Body.String())
}
if strings.Contains(strings.ToLower(w.Body.String()), "<html") {
t.Fatalf("GET /mcp must not fall back to the SPA index.html; body=%q", w.Body.String())
}
}
func TestMCP_DeleteReturnsMethodNotAllowed(t *testing.T) {
t.Chdir(t.TempDir())
r := setupMCPMethodRouter(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/mcp", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("DELETE /mcp (session terminate) must return 405 when sessions are not implemented; got %d",
w.Code)
}
}
+117
View File
@@ -0,0 +1,117 @@
package controller
import (
"net"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func mcpOriginGuard() gin.HandlerFunc {
return func(c *gin.Context) {
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin == "" {
c.Next()
return
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
abortOrigin(c)
return
}
if !strings.EqualFold(u.Host, c.Request.Host) {
abortOrigin(c)
return
}
// DNS rebinding 防线:当 dashboard 明确仅暴露在 loopback 上时,浏览器只
// 应通过 loopback hostname 命中本服务。任何带 Origin 的请求若 Host 不是
// loopback 字面量,说明它解析自一个对外 DNS 名(典型攻击:evil.example
// 解析到 127.0.0.1,Host == Origin == evil.example 等式成立但实际打的是
// 用户本机 dashboard)。绑公网 IP / 未指定地址 / 公网 hostname 的部署
// 跳过此检查 —— 那种部署下 Host 本来就是公网域名,再要求 loopback
// 会把合法的同源前端请求一刀切。
if dashboardListensOnLoopback() && !isLoopbackHostname(hostnameOnly(c.Request.Host)) {
abortOrigin(c)
return
}
c.Next()
}
}
// dashboardListensOnLoopback 判定 dashboard 是否仅暴露在 loopback 上。
//
// 之前把未指定地址(空字符串、`0.0.0.0`、`::`、`*`)也当 loopback 部署,理由是
// 这些场景下浏览器仍可能通过 127.0.0.1 访问,rebinding 风险存在。问题是绝大多数
// 生产部署就是 `listen_host=""` / `0.0.0.0`,配合公网域名访问;那种部署下浏览器
// 同源 POST 的 Host 是公网域名而不是 loopback,旧逻辑会把合法的同源前端请求一刀切。
//
// 现在只在 ListenHost 明确写成 loopback 字面量时才开启 loopback 严格化:
// - 显式 loopback IP(127.0.0.1 / ::1)或 localhost → 视为 loopback 部署,
// 此时任何带 Origin 的请求 Host 不是 loopback 都拒掉(DNS rebinding 防线);
// - 空 / `0.0.0.0` / `::` / `*` / 公网 IP / hostname → 不当 loopback 部署,
// 仅做 Origin == Host 的同源校验,不再额外要求 Host 必须是 loopback。
//
// 调用方仍然在配额(Origin == Host)外用 isLoopbackHostname 校验 Host,所以
// 当本机用户拿 127.0.0.1 访问绑公网 IP 的 dashboard 时这条防线依然生效(Host
// 是 loopback,Origin 是公网域名,hostnameOnly 比较会失败)。
func dashboardListensOnLoopback() bool {
if singleton.Conf == nil {
return false
}
host := strings.TrimSpace(singleton.Conf.ListenHost)
if host == "" {
return false
}
host = strings.Trim(host, "[]")
switch host {
case "0.0.0.0", "::", "*":
return false
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return strings.EqualFold(host, "localhost")
}
func hostnameOnly(hostport string) string {
if i := strings.LastIndex(hostport, ":"); i >= 0 {
if !strings.Contains(hostport[i:], "]") {
return hostport[:i]
}
}
return hostport
}
func isLoopbackHostname(host string) bool {
host = strings.Trim(host, "[]")
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
func abortOrigin(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: origin not allowed",
})
}
func mcpMethodNotAllowed(c *gin.Context) {
c.Header("Allow", "POST")
c.JSON(http.StatusMethodNotAllowed, model.CommonResponse[any]{
Success: false,
Error: "MCP endpoint only accepts POST (Streamable HTTP without standalone SSE / sessions)",
})
}
+109
View File
@@ -0,0 +1,109 @@
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func setupMCPOriginRouter(t *testing.T) (*httptest.Server, string, func()) {
t.Helper()
cleanup, uid := setupMCPTest(t)
_, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/mcp", mcpOriginGuard(), apiTokenAuthMiddleware(), mcpEndpoint)
r.GET("/mcp/download/:token", mcpOriginGuard(), transferDownloadHandler)
r.POST("/mcp/upload/:token", mcpOriginGuard(), transferUploadHandler)
ts := httptest.NewServer(r)
return ts, plain, func() {
ts.Close()
cleanup()
}
}
func TestMCP_DisallowsCrossOriginRequest(t *testing.T) {
ts, tok, cleanup := setupMCPOriginRouter(t)
defer cleanup()
req, _ := http.NewRequest("POST", ts.URL+"/mcp", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Origin", "http://evil.example.com")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestMCP_AllowsRequestWithoutOriginHeader(t *testing.T) {
ts, tok, cleanup := setupMCPOriginRouter(t)
defer cleanup()
req, _ := http.NewRequest("POST", ts.URL+"/mcp", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestMCP_AllowsSameHostOrigin(t *testing.T) {
ts, tok, cleanup := setupMCPOriginRouter(t)
defer cleanup()
req, _ := http.NewRequest("POST", ts.URL+"/mcp", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Origin", "http://"+req.Host)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
}
// 公网部署回归:ListenHost 是 0.0.0.0/未指定时,前端会以公网 Host 同源访问。
// 这条以前会被 dashboardListensOnLoopback 误判为 loopback 部署进而拒掉;
// 现在必须放行,否则正常生产环境的 admin frontend MCP 入口直接 403。
func TestMCP_PublicDeployment_AllowsPublicSameOrigin(t *testing.T) {
ts, tok, cleanup := setupMCPOriginRouter(t)
defer cleanup()
req, _ := http.NewRequest("POST", ts.URL+"/mcp", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok)
req.Host = "dashboard.example.com"
req.Header.Set("Origin", "https://dashboard.example.com")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
}
// 显式绑 loopback 时仍然要执行 DNS rebinding 防线:Host 是公网域名 → 403。
func TestMCP_LoopbackDeployment_RejectsPublicHost(t *testing.T) {
ts, tok, cleanup := setupMCPOriginRouter(t)
defer cleanup()
prev := singleton.Conf.ListenHost
singleton.Conf.ListenHost = "127.0.0.1"
defer func() { singleton.Conf.ListenHost = prev }()
req, _ := http.NewRequest("POST", ts.URL+"/mcp", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok)
req.Host = "dashboard.example.com"
req.Header.Set("Origin", "https://dashboard.example.com")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
+98
View File
@@ -0,0 +1,98 @@
package controller
import (
"sync"
"time"
)
// MCPRateLimiter 实现按 token 的双层 token bucket:
// - 秒级:默认 10 req/s,应对单个 LLM 突发
// - 分钟级:默认 120 req/min,应对长时间刷
//
// 实现走简单 sliding window(按桶截断的 counter),轻量、O(1);
// 进程内即可,未持久化——重启等价于配额刷新,可接受。
type MCPRateLimiter struct {
mu sync.Mutex
perToken map[uint64]*tokenWindow
secLimit int
minLimit int
lastPrune time.Time
clock func() time.Time
}
type tokenWindow struct {
secBucketStart time.Time
secCount int
minBucketStart time.Time
minCount int
}
// mcpRateLimiterPruneInterval bounds how often Allow sweeps the map. Without
// eviction the map kept one entry per token ID ever seen, so PAT churn grew
// it without bound. A token idle longer than its minute bucket carries no
// live budget, so dropping it is lossless; the interval keeps the sweep
// amortized O(1) per call instead of O(map) every call.
const mcpRateLimiterPruneInterval = time.Minute
func newMCPRateLimiter(secLimit, minLimit int) *MCPRateLimiter {
return newMCPRateLimiterWithClock(secLimit, minLimit, time.Now)
}
func newMCPRateLimiterWithClock(secLimit, minLimit int, clock func() time.Time) *MCPRateLimiter {
return &MCPRateLimiter{
perToken: make(map[uint64]*tokenWindow),
secLimit: secLimit,
minLimit: minLimit,
clock: clock,
}
}
// pruneStaleLocked drops windows whose minute bucket started more than one
// minute ago: such a token has no accumulated budget left, so removing it
// cannot change any future Allow decision. Caller must hold r.mu.
func (r *MCPRateLimiter) pruneStaleLocked(now time.Time) {
for id, w := range r.perToken {
if now.Sub(w.minBucketStart) >= time.Minute {
delete(r.perToken, id)
}
}
}
// Allow 返回是否允许本次调用。被拒返回 false。
// tokenID = 0 时不限流(管理路径或匿名)。
func (r *MCPRateLimiter) Allow(tokenID uint64) bool {
if tokenID == 0 {
return true
}
r.mu.Lock()
defer r.mu.Unlock()
// Sampling while holding the state lock linearizes the clock read with the
// bucket update, so a delayed sample cannot commit after a newer rollover.
now := r.clock()
if now.Sub(r.lastPrune) >= mcpRateLimiterPruneInterval {
r.pruneStaleLocked(now)
r.lastPrune = now
}
w, ok := r.perToken[tokenID]
if !ok {
w = &tokenWindow{secBucketStart: now, minBucketStart: now}
r.perToken[tokenID] = w
}
if now.Sub(w.secBucketStart) >= time.Second {
w.secBucketStart = now
w.secCount = 0
}
if now.Sub(w.minBucketStart) >= time.Minute {
w.minBucketStart = now
w.minCount = 0
}
if w.secCount >= r.secLimit || w.minCount >= r.minLimit {
return false
}
w.secCount++
w.minCount++
return true
}
// 全局单例,参数固定(生产可观测后再考虑配置化)。
var mcpRateLimiterShared = newMCPRateLimiter(10, 120)
@@ -0,0 +1,51 @@
//go:build agentcompat
package controller
import (
"time"
"github.com/gin-gonic/gin"
)
type agentcompatMCPRateLimitProbeRequest struct {
}
type agentcompatMCPRateLimitProbeResponse struct {
SecondAllowedCount int `json:"second_allowed_count"`
SecondRejectedAtCount int `json:"second_rejected_at_count"`
MinuteAllowedCount int `json:"minute_allowed_count"`
MinuteRejectedAtCount int `json:"minute_rejected_at_count"`
}
func agentcompatMCPRateLimitProbeRoute(context *gin.Context) (agentcompatMCPRateLimitProbeResponse, error) {
var request agentcompatMCPRateLimitProbeRequest
if err := decodeAgentcompatJSON(context, &request); err != nil {
return agentcompatMCPRateLimitProbeResponse{}, err
}
return runAgentcompatMCPRateLimitProbe(request)
}
func runAgentcompatMCPRateLimitProbe(request agentcompatMCPRateLimitProbeRequest) (agentcompatMCPRateLimitProbeResponse, error) {
response := agentcompatMCPRateLimitProbeResponse{}
secondNow := time.Unix(1_700_000_000, 0)
secondLimiter := newMCPRateLimiterWithClock(10, 120, func() time.Time { return secondNow })
for requestNumber := 1; requestNumber <= 11; requestNumber++ {
if secondLimiter.Allow(1) {
response.SecondAllowedCount++
} else if response.SecondRejectedAtCount == 0 {
response.SecondRejectedAtCount = requestNumber
}
}
minuteNow := time.Unix(1_700_000_000, 0)
minuteLimiter := newMCPRateLimiterWithClock(10_000, 120, func() time.Time { return minuteNow })
for requestNumber := 1; requestNumber <= 121; requestNumber++ {
if minuteLimiter.Allow(1) {
response.MinuteAllowedCount++
} else if response.MinuteRejectedAtCount == 0 {
response.MinuteRejectedAtCount = requestNumber
}
minuteNow = minuteNow.Add(100 * time.Millisecond)
}
return response, nil
}
@@ -0,0 +1,102 @@
//go:build agentcompat
package controller
import (
"bytes"
"encoding/json"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
func TestAgentcompatMCPRateLimitProbe_returnsTypedBoundaryCountsWithoutSharedState(t *testing.T) {
// Given
request := agentcompatMCPRateLimitProbeRequest{}
originalLimiter := mcpRateLimiterShared
// When
response, err := runAgentcompatMCPRateLimitProbe(request)
// Then
if err != nil {
t.Fatalf("probe returned error: %v", err)
}
if response.SecondAllowedCount != 10 || response.SecondRejectedAtCount != 11 || response.MinuteAllowedCount != 120 || response.MinuteRejectedAtCount != 121 {
t.Fatalf("probe result = %+v, want second=10/11 minute=120/121", response)
}
t.Logf("typed probe result: second=%d/%d minute=%d/%d", response.SecondAllowedCount, response.SecondRejectedAtCount, response.MinuteAllowedCount, response.MinuteRejectedAtCount)
if mcpRateLimiterShared != originalLimiter {
t.Fatal("probe mutated the shared production limiter")
}
}
func TestAgentcompatMCPRateLimitProbe_isRepeatableAndConcurrentSafe(t *testing.T) {
// Given
request := agentcompatMCPRateLimitProbeRequest{}
results := make(chan agentcompatMCPRateLimitProbeResponse, 8)
errors := make(chan error, 8)
// When
var waitGroup sync.WaitGroup
for probeNumber := 0; probeNumber < 8; probeNumber++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
response, err := runAgentcompatMCPRateLimitProbe(request)
if err != nil {
errors <- err
return
}
results <- response
}()
}
waitGroup.Wait()
close(results)
close(errors)
// Then
for err := range errors {
t.Fatalf("concurrent probe returned error: %v", err)
}
for response := range results {
if response.SecondAllowedCount != 10 || response.SecondRejectedAtCount != 11 || response.MinuteAllowedCount != 120 || response.MinuteRejectedAtCount != 121 {
t.Fatalf("concurrent probe result = %+v, want second=10/11 minute=120/121", response)
}
}
}
func TestAgentcompatMCPRateLimitProbe_rejectsCallerControlledParameters(t *testing.T) {
// Given
gin.SetMode(gin.TestMode)
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"token_id":7}`))
context.Request.Header.Set("Content-Type", "application/json")
// When
var request agentcompatMCPRateLimitProbeRequest
err := decodeAgentcompatJSON(context, &request)
// Then
if err == nil {
t.Fatal("caller-controlled rate probe parameters must be rejected")
}
context.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{}`))
if err := decodeAgentcompatJSON(context, &request); err != nil {
t.Fatalf("canonical empty probe request must be accepted: %v", err)
}
if _, err := json.Marshal(request); err != nil {
t.Fatalf("canonical request must remain JSON encodable: %v", err)
}
}
func TestAgentcompatMCPRateLimitProbeRejectsTrailingJSON(t *testing.T) {
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{}{}`))
var request agentcompatMCPRateLimitProbeRequest
if err := decodeAgentcompatJSON(context, &request); err == nil {
t.Fatal("trailing JSON values must be rejected")
}
}
@@ -0,0 +1,197 @@
package controller
import (
"sync"
"sync/atomic"
"testing"
"time"
)
type mcpRateLimitFakeClock struct {
now time.Time
}
func (clock *mcpRateLimitFakeClock) Now() time.Time {
return clock.now
}
func (clock *mcpRateLimitFakeClock) Advance(duration time.Duration) {
clock.now = clock.now.Add(duration)
}
func TestMCPRateLimiter_baselinePreservesAnonymousBypassAndBucketReset(t *testing.T) {
// Given
limiter := newMCPRateLimiter(3, 3)
// When
anonymousAllowed := limiter.Allow(0)
firstAllowed := limiter.Allow(7)
secondAllowed := limiter.Allow(7)
thirdAllowed := limiter.Allow(7)
fourthRejected := limiter.Allow(7)
// Then
if !anonymousAllowed || !firstAllowed || !secondAllowed || !thirdAllowed || fourthRejected {
t.Fatalf("baseline limiter semantics changed: anonymous=%t first=%t second=%t third=%t rejected=%t", anonymousAllowed, firstAllowed, secondAllowed, thirdAllowed, fourthRejected)
}
}
func TestMCPRateLimiter_allowsTenAndRejectsElevenWithinOneSecond(t *testing.T) {
// Given
clock := &mcpRateLimitFakeClock{now: time.Unix(1_700_000_000, 0)}
limiter := newMCPRateLimiterWithClock(10, 120, clock.Now)
// When
allowed := 0
for requestNumber := 1; requestNumber <= 11; requestNumber++ {
if limiter.Allow(7) {
allowed++
}
}
// Then
if allowed != 10 {
t.Fatalf("allowed count = %d, want 10", allowed)
}
if limiter.Allow(7) {
t.Fatal("twelfth request must remain rejected in the same second")
}
}
func TestMCPRateLimiter_allowsOneAfterSecondBucketRollover(t *testing.T) {
// Given
clock := &mcpRateLimitFakeClock{now: time.Unix(1_700_000_000, 0)}
limiter := newMCPRateLimiterWithClock(10, 120, clock.Now)
for requestNumber := 1; requestNumber <= 10; requestNumber++ {
if !limiter.Allow(7) {
t.Fatalf("request %d must be allowed before rollover", requestNumber)
}
}
// When
clock.Advance(time.Second)
// Then
if !limiter.Allow(7) {
t.Fatal("request after one-second bucket rollover must be allowed")
}
}
func TestMCPRateLimiter_allows120AndRejects121WithinOneMinute(t *testing.T) {
// Given
clock := &mcpRateLimitFakeClock{now: time.Unix(1_700_000_000, 0)}
limiter := newMCPRateLimiterWithClock(10_000, 120, clock.Now)
// When
allowed := 0
for requestNumber := 1; requestNumber <= 121; requestNumber++ {
if limiter.Allow(7) {
allowed++
}
clock.Advance(100 * time.Millisecond)
}
// Then
if allowed != 120 {
t.Fatalf("allowed count = %d, want 120", allowed)
}
}
func TestMCPRateLimiter_independentTokensKeepIndependentBudgets(t *testing.T) {
// Given
clock := &mcpRateLimitFakeClock{now: time.Unix(1_700_000_000, 0)}
limiter := newMCPRateLimiterWithClock(1, 120, clock.Now)
// When
firstTokenAllowed := limiter.Allow(7)
firstTokenRejected := limiter.Allow(7)
secondTokenAllowed := limiter.Allow(8)
// Then
if !firstTokenAllowed || firstTokenRejected || !secondTokenAllowed {
t.Fatalf("token budgets are not independent: first allowed=%t rejected=%t second allowed=%t", firstTokenAllowed, firstTokenRejected, secondTokenAllowed)
}
}
func TestMCPRateLimiter_clockControlledCallsDoNotMutateSharedLimiter(t *testing.T) {
// Given
originalLimiter := mcpRateLimiterShared
clock := &mcpRateLimitFakeClock{now: time.Unix(1_700_000_000, 0)}
limiter := newMCPRateLimiterWithClock(1, 1, clock.Now)
// When
if !limiter.Allow(7) {
t.Fatal("isolated limiter request must be allowed")
}
// Then
if mcpRateLimiterShared != originalLimiter {
t.Fatal("isolated limiter changed shared production limiter ownership")
}
}
func TestMCPRateLimiter_concurrentCallsAtSecondBoundaryAllowExactlyLimit(t *testing.T) {
// Given
clock := &mcpRateLimitFakeClock{now: time.Unix(1_700_000_000, 0)}
limiter := newMCPRateLimiterWithClock(10, 120, clock.Now)
var allowed atomic.Int32
var waitGroup sync.WaitGroup
// When
for requestNumber := 0; requestNumber < 20; requestNumber++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
if limiter.Allow(7) {
allowed.Add(1)
}
}()
}
waitGroup.Wait()
// Then
if got := allowed.Load(); got != 10 {
t.Fatalf("concurrent allowed count = %d, want 10", got)
}
}
func TestMCPRateLimiter_minuteBucketRolloverRestoresBudget(t *testing.T) {
// Given
clock := &mcpRateLimitFakeClock{now: time.Unix(1_700_000_000, 0)}
limiter := newMCPRateLimiterWithClock(10_000, 1, clock.Now)
if !limiter.Allow(7) {
t.Fatal("first request must be allowed")
}
if limiter.Allow(7) {
t.Fatal("second request must be rejected before minute rollover")
}
// When
clock.Advance(time.Minute)
// Then
if !limiter.Allow(7) {
t.Fatal("request after minute rollover must be allowed")
}
}
func TestMCPRateLimiter_samplesClockWhileHoldingStateLock(t *testing.T) {
// Given
now := time.Unix(1_700_000_000, 0)
var limiter *MCPRateLimiter
clock := func() time.Time {
if limiter.mu.TryLock() {
limiter.mu.Unlock()
t.Fatal("clock callback acquired limiter state lock; Allow sampled before locking")
}
return now
}
limiter = newMCPRateLimiterWithClock(1, 1, clock)
// When
allowed := limiter.Allow(7)
// Then
if !allowed {
t.Fatal("initial request must be allowed")
}
}
@@ -0,0 +1,86 @@
package controller
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func mcpEndpointTestCtx(t *testing.T, tok *model.APIToken, body any) (*gin.Context, *httptest.ResponseRecorder, error) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, engine := gin.CreateTestContext(w)
require.NotNil(t, engine)
raw, err := json.Marshal(body)
if err != nil {
return nil, nil, err
}
c.Request = httptest.NewRequest("POST", "/mcp", bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(apiTokenCtxKey, tok)
c.Set(model.CtxKeyAPIToken, tok)
return c, w, nil
}
func TestMCPEndpointTestCtx_surfacesJSONMarshalError(t *testing.T) {
// Given
tok := &model.APIToken{ID: 4242, UserID: 1}
unsupportedBody := map[string]any{"unsupported": func() {}}
// When
_, _, err := mcpEndpointTestCtx(t, tok, unsupportedBody)
// Then
require.Error(t, err)
}
// An unknown tool name in tools/call must still consume the per-token rate
// budget; otherwise a valid PAT can flood /mcp with does-not-exist tools and
// bypass the limiter entirely.
func TestMCPUnknownToolCountsAgainstRateLimit(t *testing.T) {
originalConf := singleton.Conf
originalLimiter := mcpRateLimiterShared
t.Cleanup(func() {
singleton.Conf = originalConf
mcpRateLimiterShared = originalLimiter
})
cfg := &model.Config{}
cfg.SetMCPEnabled(true)
singleton.Conf = &singleton.ConfigClass{Config: cfg}
mcpRateLimiterShared = newMCPRateLimiter(2, 2)
tok := &model.APIToken{ID: 4242, UserID: 1}
body := map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": map[string]any{"name": "does.not.exist", "arguments": map[string]any{}},
}
var lastStatus int
for i := 0; i < 5; i++ {
c, w, err := mcpEndpointTestCtx(t, tok, body)
require.NoError(t, err)
mcpEndpoint(c)
lastStatus = w.Code
}
if !mcpRateLimiterSaturated(tok.ID) {
t.Fatalf("after 5 unknown-tool calls with a budget of 2, the limiter must be saturated (last status %d)", lastStatus)
}
}
func mcpRateLimiterSaturated(tokenID uint64) bool {
return !mcpRateLimiterShared.Allow(tokenID)
}
@@ -0,0 +1,45 @@
package controller
import (
"sync"
"testing"
"time"
)
type linearizationClock struct {
mu sync.Mutex
times []time.Time
latest time.Time
}
func newLinearizationClock(oldTime, newTime time.Time) *linearizationClock {
return &linearizationClock{
times: []time.Time{oldTime, oldTime, newTime},
latest: newTime,
}
}
func (clock *linearizationClock) Now() time.Time {
clock.mu.Lock()
now := clock.latest
if len(clock.times) > 0 {
now = clock.times[0]
clock.times = clock.times[1:]
}
clock.mu.Unlock()
return now
}
func TestLinearizationClockReturnsLatestTimeAfterSequenceIsConsumed(t *testing.T) {
oldTime := time.Unix(1_700_000_000, 0)
newTime := oldTime.Add(time.Second)
clock := newLinearizationClock(oldTime, newTime)
for range 3 {
clock.Now()
}
if got := clock.Now(); !got.Equal(newTime) {
t.Fatalf("clock returned %s after sequence was consumed, want %s", got, newTime)
}
}
@@ -0,0 +1,18 @@
package controller
import "testing"
// TestMCPRateLimiter_DefaultsAreDoubledFromInitialBaseline pins the
// production-side per-token budget. The first iteration shipped 5/s + 60/min,
// which gated legitimate LLM bursts more aggressively than the audit /
// concurrency story required. Doubling to 10/s + 120/min keeps the bucket
// shape (same window, same per-token bookkeeping) so observed behavior
// regressions stay attributable to budget rather than algorithm changes.
func TestMCPRateLimiter_DefaultsAreDoubledFromInitialBaseline(t *testing.T) {
if mcpRateLimiterShared.secLimit != 10 {
t.Fatalf("default per-second limit = %d, want 10", mcpRateLimiterShared.secLimit)
}
if mcpRateLimiterShared.minLimit != 120 {
t.Fatalf("default per-minute limit = %d, want 120", mcpRateLimiterShared.minLimit)
}
}
@@ -0,0 +1,78 @@
package controller
import (
"bytes"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func mcpEndpointRawCtx(t *testing.T, tok *model.APIToken, raw []byte) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, engine := gin.CreateTestContext(w)
require.NotNil(t, engine)
c.Request = httptest.NewRequest("POST", "/mcp", bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(apiTokenCtxKey, tok)
c.Set(model.CtxKeyAPIToken, tok)
return c, w
}
func mcpRateLimitTestSetup(t *testing.T, budget int) *model.APIToken {
t.Helper()
originalConf := singleton.Conf
originalLimiter := mcpRateLimiterShared
t.Cleanup(func() {
singleton.Conf = originalConf
mcpRateLimiterShared = originalLimiter
})
cfg := &model.Config{}
cfg.SetMCPEnabled(true)
singleton.Conf = &singleton.ConfigClass{Config: cfg}
mcpRateLimiterShared = newMCPRateLimiter(budget, budget)
return &model.APIToken{ID: 4243, UserID: 1}
}
// A flood of tools/call requests whose params fail to parse must still consume
// the per-token budget. Otherwise a valid PAT bypasses the limiter by always
// sending malformed arguments.
func TestMCPMalformedToolsCallParamsCountsAgainstRateLimit(t *testing.T) {
tok := mcpRateLimitTestSetup(t, 2)
raw := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":"not-an-object"}`)
for i := 0; i < 5; i++ {
c, w := mcpEndpointRawCtx(t, tok, raw)
mcpEndpoint(c)
require.NotEmpty(t, w.Body.Bytes())
}
if mcpRateLimiterShared.Allow(tok.ID) {
t.Fatal("malformed tools/call params must still consume the rate budget; limiter not saturated")
}
}
// A flood of unparseable JSON-RPC envelopes from an authenticated PAT must also
// consume the budget.
func TestMCPMalformedEnvelopeCountsAgainstRateLimit(t *testing.T) {
tok := mcpRateLimitTestSetup(t, 2)
raw := []byte(`{not valid json`)
for i := 0; i < 5; i++ {
c, w := mcpEndpointRawCtx(t, tok, raw)
mcpEndpoint(c)
require.NotEmpty(t, w.Body.Bytes())
}
if mcpRateLimiterShared.Allow(tok.ID) {
t.Fatal("malformed JSON-RPC envelope must still consume the rate budget; limiter not saturated")
}
}
@@ -0,0 +1,62 @@
package controller
import (
"testing"
"time"
)
// The per-token limiter map had no eviction: every distinct token ID ever
// seen left a permanent entry. A user churning PATs (create/use/delete in a
// loop) grows the map without bound. Allow must opportunistically prune
// windows idle past the minute bucket so memory stays proportional to the
// active token set, not the historical one.
func TestMCPRateLimiter_PrunesStaleTokenWindows(t *testing.T) {
rl := newMCPRateLimiter(10, 120)
stale := time.Now().Add(-10 * time.Minute)
for i := uint64(1); i <= 500; i++ {
rl.mu.Lock()
rl.perToken[i] = &tokenWindow{
secBucketStart: stale,
minBucketStart: stale,
}
rl.mu.Unlock()
}
// A fresh request triggers a prune sweep of idle windows.
if !rl.Allow(99999) {
t.Fatal("fresh token must be allowed")
}
rl.mu.Lock()
size := len(rl.perToken)
rl.mu.Unlock()
// Only the just-active token (99999) should remain; the 500 stale ones
// must have been evicted.
if size > 1 {
t.Fatalf("stale token windows were not pruned: map still holds %d entries", size)
}
}
// Pruning must NOT evict tokens that are still within their active window,
// otherwise an in-flight client loses its accumulated count and effectively
// resets its budget.
func TestMCPRateLimiter_KeepsActiveTokenWindows(t *testing.T) {
rl := newMCPRateLimiter(10, 120)
if !rl.Allow(1) {
t.Fatal("token 1 must be allowed")
}
if !rl.Allow(2) {
t.Fatal("token 2 must be allowed")
}
rl.mu.Lock()
size := len(rl.perToken)
rl.mu.Unlock()
if size != 2 {
t.Fatalf("active token windows must be retained, got %d entries", size)
}
}
@@ -0,0 +1,14 @@
package controller
import "encoding/json"
func marshalMCPToolResult(result any) (string, error) {
if result == nil {
return "{}", nil
}
b, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(b), nil
}
@@ -0,0 +1,47 @@
package controller
import (
"encoding/json"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
var registerUnmarshalableMCPTool sync.Once
func TestMCPToolCall_unmarshalableSuccessResultReturnsExplicitToolError(t *testing.T) {
// Given
cleanup, uid := setupMCPTest(t)
defer cleanup()
registerUnmarshalableMCPTool.Do(func() {
registerMCPTool(&mcpTool{
Name: "test.unmarshalable-success",
RequiredScope: "",
Handler: func(*gin.Context, json.RawMessage) (any, error) {
return map[string]any{"unsupported": func() {}}, nil
},
})
})
tok, plainToken := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
require.NotEmpty(t, plainToken)
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
Params: jsonObj(t, toolCallParams{Name: "test.unmarshalable-success", Arguments: json.RawMessage("{}")}),
})
// When
mcpEndpoint(c)
// Then
_, result := decodeRPC(w)
require.NotNil(t, result)
require.True(t, result.IsError)
require.Nil(t, result.StructuredContent)
require.Len(t, result.Content, 1)
require.True(t, strings.Contains(result.Content[0].Text, "encode"), result.Content[0].Text)
}
@@ -0,0 +1,197 @@
package controller
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// MCP 协议兼容性集成测试:用 modelcontextprotocol/go-sdk 官方 Go MCP client
// 对 dashboard /mcp 跑完整 initialize + tools/list + tools/call。
// 协议层用官方 SDK 严格编解码 — 任何与 MCP spec 的偏差都会被立即报错。
type sdkPATRoundTripper struct {
base http.RoundTripper
token string
}
func (rt *sdkPATRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Authorization", "Bearer "+rt.token)
return rt.base.RoundTrip(req)
}
func sdkTransport(endpoint, token string) *mcp.StreamableClientTransport {
return &mcp.StreamableClientTransport{
Endpoint: endpoint,
HTTPClient: &http.Client{
Transport: &sdkPATRoundTripper{base: http.DefaultTransport, token: token},
Timeout: 5 * time.Second,
},
// /mcp 当前只实现 POST 半边 Streamable HTTP;GET SSE 通道未实现也不计划
// 短期内上线(不需要 server→client 主动推送)。SDK 默认会试图发 GET,
// 关掉 standalone SSE 即可严格互通。
DisableStandaloneSSE: true,
}
}
func setupSDKCompat(t *testing.T) (string, string, func()) {
t.Helper()
cleanupBase, uid := setupMCPTest(t)
srv, _ := singleton.ServerShared.Get(7)
srv.SetTaskStream(&e2eStream{dispatch: agentSim})
_, plain := mkToken(t, uid, []string{
model.ScopeInventoryRead,
model.ScopeInventoryDelete,
model.ScopeServerRead,
model.ScopeServerWrite,
model.ScopeServerDelete,
model.ScopeServerExec,
}, nil)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/mcp", apiTokenAuthMiddleware(), mcpEndpoint)
ts := httptest.NewServer(r)
return ts.URL + "/mcp", plain, func() {
ts.Close()
cleanupBase()
}
}
func TestSDKClient_InitializeHandshake(t *testing.T) {
endpoint, token, cleanup := setupSDKCompat(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
require.NoError(t, err, "official Go SDK must initialize against /mcp")
defer session.Close()
}
func TestSDKClient_ToolsList(t *testing.T) {
endpoint, token, cleanup := setupSDKCompat(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
require.NoError(t, err)
defer session.Close()
lst, err := session.ListTools(ctx, nil)
require.NoError(t, err)
names := make(map[string]bool, len(lst.Tools))
for _, tl := range lst.Tools {
names[tl.Name] = true
}
for _, must := range []string{
"meta.whoami",
"server.list", "server.get", "server.exec",
"fs.list", "fs.read", "fs.write", "fs.delete",
"fs.download_url", "fs.upload_url",
} {
require.Truef(t, names[must], "tools/list missing %q", must)
}
}
func TestSDKClient_Whoami(t *testing.T) {
endpoint, token, cleanup := setupSDKCompat(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
require.NoError(t, err)
defer session.Close()
res, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "meta.whoami",
Arguments: map[string]any{},
})
require.NoError(t, err)
require.False(t, res.IsError)
tc, ok := res.Content[0].(*mcp.TextContent)
require.True(t, ok)
var payload map[string]any
require.NoError(t, json.Unmarshal([]byte(tc.Text), &payload))
require.NotZero(t, payload["user_id"])
require.NotEmpty(t, payload["scopes"])
}
func TestSDKClient_ServerExec(t *testing.T) {
endpoint, token, cleanup := setupSDKCompat(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
require.NoError(t, err)
defer session.Close()
res, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "server.exec",
Arguments: map[string]any{
"server_id": 7,
"cmd": "echo",
},
})
require.NoError(t, err)
require.False(t, res.IsError, "exec failed: %v", res.Content)
tc := res.Content[0].(*mcp.TextContent)
require.Contains(t, tc.Text, "simulated")
}
func TestSDKClient_FSLifecycle(t *testing.T) {
endpoint, token, cleanup := setupSDKCompat(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
session, err := client.Connect(ctx, sdkTransport(endpoint, token), nil)
require.NoError(t, err)
defer session.Close()
path := t.TempDir() + "/sdk.txt"
for _, step := range []struct {
name string
args map[string]any
}{
{"fs.write", map[string]any{"server_id": 7, "path": path, "content": "via-sdk", "encoding": "utf8"}},
{"fs.read", map[string]any{"server_id": 7, "path": path}},
{"fs.delete", map[string]any{"server_id": 7, "path": path}},
} {
res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: step.name, Arguments: step.args})
require.NoError(t, err, step.name)
require.False(t, res.IsError, "%s failed: %v", step.name, res.Content)
}
}
func TestSDKClient_BadPAT(t *testing.T) {
endpoint, _, cleanup := setupSDKCompat(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client := mcp.NewClient(&mcp.Implementation{Name: "nezha-it", Version: "v0"}, nil)
_, err := client.Connect(ctx, sdkTransport(endpoint, "nzp_invalid"), nil)
require.Error(t, err)
}
Loaded 100 of 840 files, more files were not shown because too many files have changed in this diff. Show more