mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 05:36:15 +01:00
Compare commits
205 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fb9b856f | ||
|
|
66bca11d36 | ||
|
|
86e87f0d47 | ||
|
|
fadfc5d81d | ||
|
|
fc39ff1e4d | ||
|
|
82ccfc66e0 | ||
|
|
890bad1c39 | ||
|
|
9c458885f1 | ||
|
|
d2aed0dc72 | ||
|
|
3dbcb5d7da | ||
|
|
57a1a8b39e | ||
|
|
ab81c04569 | ||
|
|
0c32be3bea | ||
|
|
81d43fbf6e | ||
|
|
96f441de40 | ||
|
|
0e95caaee9 | ||
|
|
7697a12b42 | ||
|
|
94245a9ba4 | ||
|
|
b084814aea | ||
|
|
cce74246ee | ||
|
|
a3420b8c67 | ||
|
|
e1bb17ee9e | ||
|
|
52983f60b7 | ||
|
|
1f053fd85d | ||
|
|
a989d121d3 | ||
|
|
50d2406423 | ||
|
|
059d2d0a5b | ||
|
|
621bef30b5 | ||
|
|
5f4d3dc730 | ||
|
|
8fa9aece63 | ||
|
|
2f1a022e2a | ||
|
|
4815cd29bc | ||
|
|
e49bfaf5d7 | ||
|
|
b13915b76f | ||
|
|
e2a57dc43b | ||
|
|
7222224b40 | ||
|
|
02ff475b84 | ||
|
|
09cd8d0db9 | ||
|
|
36f1a0c53b | ||
|
|
0b0e94e045 | ||
|
|
20ca6edf81 | ||
|
|
1990f8c6df | ||
|
|
6e9dbf863f | ||
|
|
fa921d77f1 | ||
|
|
ff854d481d | ||
|
|
4ce491fe48 | ||
|
|
493bae7eb6 | ||
|
|
ae5532aa36 | ||
|
|
a1eae6413a | ||
|
|
ee52bf1fbf | ||
|
|
2ff0bd6b44 | ||
|
|
a385233b7d | ||
|
|
f5648a415d | ||
|
|
556fb18953 | ||
|
|
a482f78739 | ||
|
|
4a580ce972 | ||
|
|
e07558237f | ||
|
|
fb3c70a1bc | ||
|
|
cba4d60895 | ||
|
|
8b655ef2b9 | ||
|
|
0188418055 | ||
|
|
72334c42d0 | ||
|
|
0638ff3c21 | ||
|
|
b64318d9e8 | ||
|
|
0f5b1b5157 | ||
|
|
3c4ae46f50 | ||
|
|
c158b1aeeb | ||
|
|
684d92c497 | ||
|
|
bbd9595ec0 | ||
|
|
bbebb3e301 | ||
|
|
9d25181d1d | ||
|
|
7ba1f366ba | ||
|
|
37c6b920f9 | ||
|
|
49db81dac8 | ||
|
|
a9e90ec19c | ||
|
|
2ad60507b7 | ||
|
|
12059ee3db | ||
|
|
de56544ca3 | ||
|
|
065c7facb6 | ||
|
|
630c92c139 | ||
|
|
e11d452d91 | ||
|
|
99c7f7bd8a | ||
|
|
8af3a0eb5b | ||
|
|
5f7950b474 | ||
|
|
df9e2dec28 | ||
|
|
a0f271545a | ||
|
|
aa2bc9f118 | ||
|
|
b22ae87022 | ||
|
|
79e79079bc | ||
|
|
1811ebdee4 | ||
|
|
137f3f3e24 | ||
|
|
ed1d1e77c0 | ||
|
|
8c36dd1caa | ||
|
|
57bfe72486 | ||
|
|
75f66b0246 | ||
|
|
ce93d54aa7 | ||
|
|
39dbe0eac5 | ||
|
|
7282044f80 | ||
|
|
d77c37c0b0 | ||
|
|
e362cbbca5 | ||
|
|
118544926b | ||
|
|
d4bb0a0a30 | ||
|
|
fe5e35d1a9 | ||
|
|
60a6ae2caa | ||
|
|
80338d36aa | ||
|
|
f0d2c242e8 | ||
|
|
559f83d99c | ||
|
|
d3a751ee6c | ||
|
|
fb70a166fa | ||
|
|
c12457b707 | ||
|
|
3e53d73d56 | ||
|
|
80338c5e98 | ||
|
|
249cd8ad19 | ||
|
|
ccdff46370 | ||
|
|
91679b5cc0 | ||
|
|
6953edf59e | ||
|
|
b91c77ec92 | ||
|
|
3ac0b185d1 | ||
|
|
1e675cabb5 | ||
|
|
5f44965c2c | ||
|
|
f080929296 | ||
|
|
f055658eba | ||
|
|
e430c747fe | ||
|
|
ca62b1db36 | ||
|
|
38569b7057 | ||
|
|
203244090f | ||
|
|
2bed722045 | ||
|
|
13f3a52760 | ||
|
|
16b9827c70 | ||
|
|
0fc352d7fc | ||
|
|
8a2bee11d4 | ||
|
|
485f7d16ff | ||
|
|
46fdc94cb8 | ||
|
|
261f7fb76c | ||
|
|
18d9258907 | ||
|
|
9d7fb8ab80 | ||
|
|
3730a78e5a | ||
|
|
7cdd0907e8 | ||
|
|
3586f73f30 | ||
|
|
752ccc6beb | ||
|
|
f577476c81 | ||
|
|
49ae424698 | ||
|
|
d4fd19522b | ||
|
|
5c047e4afd | ||
|
|
6576141f54 | ||
|
|
926e807020 | ||
|
|
d91847c6c5 | ||
|
|
0abd88270c | ||
|
|
806c4e51c5 | ||
|
|
6520783fe9 | ||
|
|
48c8a3a4a5 | ||
|
|
e0c839f78c | ||
|
|
1ba362bafe | ||
|
|
b5d55ead4a | ||
|
|
4f879ccc66 | ||
|
|
cd9e0f7b5b | ||
|
|
780644eeae | ||
|
|
71f081da20 | ||
|
|
11c61bcf42 | ||
|
|
402a1584d7 | ||
|
|
99d61a0193 | ||
|
|
5ddb200a75 | ||
|
|
faa247dbda | ||
|
|
6d1cec3c42 | ||
|
|
529df84273 | ||
|
|
e0e21eedd6 | ||
|
|
4356ffbe9b | ||
|
|
be1366b785 | ||
|
|
3dc7e02ed0 | ||
|
|
d67d638a6b | ||
|
|
7b36036455 | ||
|
|
1b58560acf | ||
|
|
1627c41f84 | ||
|
|
4395520a28 | ||
|
|
8c52f30a71 | ||
|
|
46316ebffa | ||
|
|
0b04f60b6c | ||
|
|
20b822d072 | ||
|
|
ca7642cc91 | ||
|
|
68009c85a5 | ||
|
|
1c7c64c4aa | ||
|
|
b05966d30b | ||
|
|
ea90f6a596 | ||
|
|
f1e43b2593 | ||
|
|
748d18321d | ||
|
|
ae84919c39 | ||
|
|
b23221702e | ||
|
|
4d5b096230 | ||
|
|
7caf7d1b31 | ||
|
|
6107f52d07 | ||
|
|
f4fb7a89e5 | ||
|
|
5439066f4d | ||
|
|
7c18f3d8b4 | ||
|
|
63af81666b | ||
|
|
c0a6153a43 | ||
|
|
df334caca6 | ||
|
|
ffb3ec0477 | ||
|
|
3a97edd0d5 | ||
|
|
ab1d1c1273 | ||
|
|
0fb39edae4 | ||
|
|
3a977a8e1f | ||
|
|
081979de24 | ||
|
|
23fe189797 | ||
|
|
e9d429b9b8 | ||
|
|
99202c85b6 |
47
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
47
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,8 +1,19 @@
|
|||||||
name: 🐛 Bug report
|
name: 🐛 Bug report
|
||||||
description: Report a new bug or issue.
|
description: Report a new bug or issue.
|
||||||
title: '[Bug]: '
|
title: '[Bug]: '
|
||||||
labels: ['bug']
|
labels: ['bug', "needs confirmation"]
|
||||||
body:
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Component
|
||||||
|
description: Which part of Beszel is this about?
|
||||||
|
options:
|
||||||
|
- Hub
|
||||||
|
- Agent
|
||||||
|
- Hub & Agent
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
@@ -43,6 +54,39 @@ body:
|
|||||||
3. Pour it into a cup.
|
3. Pour it into a cup.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
description: Which category does this relate to most?
|
||||||
|
options:
|
||||||
|
- Metrics
|
||||||
|
- Charts & Visualization
|
||||||
|
- Settings & Configuration
|
||||||
|
- Notifications & Alerts
|
||||||
|
- Authentication
|
||||||
|
- Installation
|
||||||
|
- Performance
|
||||||
|
- UI / UX
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: metrics
|
||||||
|
attributes:
|
||||||
|
label: Affected Metrics
|
||||||
|
description: If applicable, which specific metric does this relate to most?
|
||||||
|
options:
|
||||||
|
- CPU
|
||||||
|
- Memory
|
||||||
|
- Storage
|
||||||
|
- Network
|
||||||
|
- Containers
|
||||||
|
- GPU
|
||||||
|
- Sensors
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
id: system
|
id: system
|
||||||
attributes:
|
attributes:
|
||||||
@@ -61,7 +105,6 @@ body:
|
|||||||
id: install-method
|
id: install-method
|
||||||
attributes:
|
attributes:
|
||||||
label: Installation method
|
label: Installation method
|
||||||
default: 0
|
|
||||||
options:
|
options:
|
||||||
- Docker
|
- Docker
|
||||||
- Binary
|
- Binary
|
||||||
|
|||||||
60
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
60
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,8 +1,19 @@
|
|||||||
name: 🚀 Feature request
|
name: 🚀 Feature request
|
||||||
description: Request a new feature or change.
|
description: Request a new feature or change.
|
||||||
title: "[Feature]: "
|
title: "[Feature]: "
|
||||||
labels: ["enhancement"]
|
labels: ["enhancement", "needs review"]
|
||||||
body:
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Component
|
||||||
|
description: Which part of Beszel is this about?
|
||||||
|
options:
|
||||||
|
- Hub
|
||||||
|
- Agent
|
||||||
|
- Hub & Agent
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
||||||
@@ -11,8 +22,55 @@ body:
|
|||||||
label: Describe the feature you would like to see
|
label: Describe the feature you would like to see
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: motivation
|
||||||
|
attributes:
|
||||||
|
label: Motivation / Use Case
|
||||||
|
description: Why do you want this feature? What problem does it solve?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Describe how you would like to see this feature implemented
|
label: Describe how you would like to see this feature implemented
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: Please attach any relevant screenshots, such as images from your current solution or similar implementations.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
description: Which category does this relate to most?
|
||||||
|
options:
|
||||||
|
- Metrics
|
||||||
|
- Charts & Visualization
|
||||||
|
- Settings & Configuration
|
||||||
|
- Notifications & Alerts
|
||||||
|
- Authentication
|
||||||
|
- Installation
|
||||||
|
- Performance
|
||||||
|
- UI / UX
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: metrics
|
||||||
|
attributes:
|
||||||
|
label: Affected Metrics
|
||||||
|
description: If applicable, which specific metric does this relate to most?
|
||||||
|
options:
|
||||||
|
- CPU
|
||||||
|
- Memory
|
||||||
|
- Storage
|
||||||
|
- Network
|
||||||
|
- Containers
|
||||||
|
- GPU
|
||||||
|
- Sensors
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
33
.github/pull_request_template.md
vendored
Normal file
33
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
## 📃 Description
|
||||||
|
|
||||||
|
A short description of the pull request changes should go here and the sections below should list in detail all changes. You can remove the sections you don't need.
|
||||||
|
|
||||||
|
## 📖 Documentation
|
||||||
|
|
||||||
|
Add a link to the PR for [documentation](https://github.com/henrygd/beszel-docs) changes.
|
||||||
|
|
||||||
|
## 🪵 Changelog
|
||||||
|
|
||||||
|
### ➕ Added
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
### ✏️ Changed
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
### 🔧 Fixed
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
### 🗑️ Removed
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
## 📷 Screenshots
|
||||||
|
|
||||||
|
If this PR has any UI/UX changes it's strongly suggested you add screenshots here.
|
||||||
41
.github/workflows/docker-images.yml
vendored
41
.github/workflows/docker-images.yml
vendored
@@ -3,7 +3,7 @@ name: Make docker images
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'xv*'
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -14,28 +14,48 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- image: henrygd/beszel
|
- image: henrygd/beszel
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Hub
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Agent
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
|
- image: henrygd/beszel-agent-nvidia
|
||||||
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||||
|
platforms: linux/amd64
|
||||||
|
registry: docker.io
|
||||||
|
username_secret: DOCKERHUB_USERNAME
|
||||||
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Hub
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Agent
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||||
|
platforms: linux/amd64
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -65,6 +85,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: ${{ matrix.image }}
|
||||||
tags: |
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
@@ -72,7 +93,9 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: github.event_name != 'pull_request'
|
env:
|
||||||
|
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
||||||
|
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||||
@@ -84,9 +107,9 @@ jobs:
|
|||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: '${{ matrix.context }}'
|
context: "${{ matrix.context }}"
|
||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
||||||
push: ${{ github.ref_type == 'tag' }}
|
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
|
|||||||
43
.github/workflows/inactivity-actions.yml
vendored
Normal file
43
.github/workflows/inactivity-actions.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: 'Issue and PR Maintenance'
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *' # runs at midnight UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-stale:
|
||||||
|
name: Close Stale Issues
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Close Stale Issues
|
||||||
|
uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Messaging
|
||||||
|
stale-issue-message: >
|
||||||
|
👋 This issue has been automatically marked as stale due to inactivity.
|
||||||
|
If this issue is still relevant, please comment to keep it open.
|
||||||
|
Without activity, it will be closed in 7 days.
|
||||||
|
|
||||||
|
close-issue-message: >
|
||||||
|
🔒 This issue has been automatically closed due to prolonged inactivity.
|
||||||
|
Feel free to open a new issue if you have further questions or concerns.
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
days-before-issue-stale: 14
|
||||||
|
days-before-issue-close: 7
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
stale-issue-label: 'stale'
|
||||||
|
remove-stale-when-updated: true
|
||||||
|
only-issue-labels: 'awaiting-requester'
|
||||||
|
|
||||||
|
# Exemptions
|
||||||
|
exempt-assignees: true
|
||||||
|
exempt-milestones: true
|
||||||
82
.github/workflows/label-from-dropdown.yml
vendored
Normal file
82
.github/workflows/label-from-dropdown.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Label issues from dropdowns
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label_from_dropdown:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Apply labels based on dropdown choices
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
|
||||||
|
const issueNumber = context.issue.number;
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
|
||||||
|
// Get the issue body
|
||||||
|
const body = context.payload.issue.body;
|
||||||
|
|
||||||
|
// Helper to find dropdown value in the body (assuming markdown format)
|
||||||
|
function extractSectionValue(heading) {
|
||||||
|
const regex = new RegExp(`### ${heading}\\s+([\\s\\S]*?)(?:\\n###|$)`, 'i');
|
||||||
|
const match = body.match(regex);
|
||||||
|
if (match) {
|
||||||
|
// Get the first non-empty line after the heading
|
||||||
|
const lines = match[1].split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
return lines[0] || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract dropdown selections
|
||||||
|
const category = extractSectionValue('Category');
|
||||||
|
const metrics = extractSectionValue('Affected Metrics');
|
||||||
|
const component = extractSectionValue('Component');
|
||||||
|
|
||||||
|
// Build labels to add
|
||||||
|
let labelsToAdd = [];
|
||||||
|
if (category) labelsToAdd.push(category);
|
||||||
|
if (metrics) labelsToAdd.push(metrics);
|
||||||
|
if (component) labelsToAdd.push(component);
|
||||||
|
|
||||||
|
// Get existing labels in the repo
|
||||||
|
const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
per_page: 100
|
||||||
|
});
|
||||||
|
const existingLabelNames = existingLabels.map(l => l.name);
|
||||||
|
|
||||||
|
// Find labels that need to be created
|
||||||
|
const labelsToCreate = labelsToAdd.filter(label => !existingLabelNames.includes(label));
|
||||||
|
|
||||||
|
// Create missing labels (with a default color)
|
||||||
|
for (const label of labelsToCreate) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
name: label,
|
||||||
|
color: 'ededed' // light gray, you can pick any hex color
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore if label already exists (race condition), otherwise rethrow
|
||||||
|
if (!e || e.status !== 422) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now apply all labels (they all exist now)
|
||||||
|
if (labelsToAdd.length > 0) {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
labels: labelsToAdd
|
||||||
|
});
|
||||||
|
}
|
||||||
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Make release and binaries
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -29,7 +29,17 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '^1.22.1'
|
go-version: "^1.22.1"
|
||||||
|
|
||||||
|
- name: Set up .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "9.0.x"
|
||||||
|
|
||||||
|
- name: Build .NET LHM executable for Windows sensors
|
||||||
|
run: |
|
||||||
|
dotnet build -c Release ./beszel/internal/agent/lhm/beszel_lhm.csproj
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: GoReleaser beszel
|
- name: GoReleaser beszel
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
@@ -39,4 +49,6 @@ jobs:
|
|||||||
version: latest
|
version: latest
|
||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||||
|
IS_FORK: ${{ github.repository_owner != 'henrygd' }}
|
||||||
|
|||||||
33
.github/workflows/vulncheck.yml
vendored
Normal file
33
.github/workflows/vulncheck.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# https://github.com/minio/minio/blob/master/.github/workflows/vulncheck.yml
|
||||||
|
|
||||||
|
name: VulnCheck
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read # to fetch code (actions/checkout)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
vulncheck:
|
||||||
|
name: Analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out code into the Go module directory
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: 1.24.x
|
||||||
|
cached: false
|
||||||
|
- name: Get official govulncheck
|
||||||
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
shell: bash
|
||||||
|
- name: Run govulncheck
|
||||||
|
run: govulncheck -C ./beszel -show verbose ./...
|
||||||
|
shell: bash
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ beszel/build
|
|||||||
beszel/site/src/locales/**/*.ts
|
beszel/site/src/locales/**/*.ts
|
||||||
*.bak
|
*.bak
|
||||||
__debug_*
|
__debug_*
|
||||||
|
beszel/internal/agent/lhm/obj
|
||||||
|
beszel/internal/agent/lhm/bin
|
||||||
|
dockerfile_agent_dev
|
||||||
|
|||||||
@@ -37,11 +37,26 @@ builds:
|
|||||||
- arm
|
- arm
|
||||||
- mips64
|
- mips64
|
||||||
- riscv64
|
- riscv64
|
||||||
|
- mipsle
|
||||||
|
- mips
|
||||||
|
- ppc64le
|
||||||
|
gomips:
|
||||||
|
- hardfloat
|
||||||
|
- softfloat
|
||||||
ignore:
|
ignore:
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: openbsd
|
- goos: openbsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: linux
|
||||||
|
goarch: mips64
|
||||||
|
gomips: softfloat
|
||||||
|
- goos: linux
|
||||||
|
goarch: mipsle
|
||||||
|
gomips: hardfloat
|
||||||
|
- goos: linux
|
||||||
|
goarch: mips
|
||||||
|
gomips: hardfloat
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
@@ -51,8 +66,8 @@ builds:
|
|||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
format: tar.gz
|
formats: [tar.gz]
|
||||||
builds:
|
ids:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
@@ -60,11 +75,11 @@ archives:
|
|||||||
{{- .Arch }}
|
{{- .Arch }}
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: [zip]
|
||||||
|
|
||||||
- id: beszel
|
- id: beszel
|
||||||
format: tar.gz
|
formats: [tar.gz]
|
||||||
builds:
|
ids:
|
||||||
- beszel
|
- beszel
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
@@ -83,13 +98,10 @@ nfpms:
|
|||||||
API access.
|
API access.
|
||||||
maintainer: henrygd <hank@henrygd.me>
|
maintainer: henrygd <hank@henrygd.me>
|
||||||
section: net
|
section: net
|
||||||
builds:
|
ids:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
formats:
|
formats:
|
||||||
- deb
|
- deb
|
||||||
# don't think this is needed with CGO_ENABLED=0
|
|
||||||
# dependencies:
|
|
||||||
# - libc6
|
|
||||||
contents:
|
contents:
|
||||||
- src: ../supplemental/debian/beszel-agent.service
|
- src: ../supplemental/debian/beszel-agent.service
|
||||||
dst: lib/systemd/system/beszel-agent.service
|
dst: lib/systemd/system/beszel-agent.service
|
||||||
@@ -120,9 +132,10 @@ scoops:
|
|||||||
repository:
|
repository:
|
||||||
owner: henrygd
|
owner: henrygd
|
||||||
name: beszel-scoops
|
name: beszel-scoops
|
||||||
homepage: 'https://beszel.dev'
|
homepage: "https://beszel.dev"
|
||||||
description: 'Agent for Beszel, a lightweight server monitoring platform.'
|
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
license: MIT
|
license: MIT
|
||||||
|
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||||
|
|
||||||
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
||||||
# chocolateys:
|
# chocolateys:
|
||||||
@@ -153,9 +166,10 @@ brews:
|
|||||||
repository:
|
repository:
|
||||||
owner: henrygd
|
owner: henrygd
|
||||||
name: homebrew-beszel
|
name: homebrew-beszel
|
||||||
homepage: 'https://beszel.dev'
|
homepage: "https://beszel.dev"
|
||||||
description: 'Agent for Beszel, a lightweight server monitoring platform.'
|
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
license: MIT
|
license: MIT
|
||||||
|
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||||
extra_install: |
|
extra_install: |
|
||||||
(bin/"beszel-agent-launcher").write <<~EOS
|
(bin/"beszel-agent-launcher").write <<~EOS
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
@@ -172,6 +186,44 @@ brews:
|
|||||||
log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
keep_alive true
|
keep_alive true
|
||||||
|
restart_delay 5
|
||||||
|
process_type :background
|
||||||
|
|
||||||
|
winget:
|
||||||
|
- ids: [beszel-agent]
|
||||||
|
name: beszel-agent
|
||||||
|
package_identifier: henrygd.beszel-agent
|
||||||
|
publisher: henrygd
|
||||||
|
license: MIT
|
||||||
|
license_url: "https://github.com/henrygd/beszel/blob/main/LICENSE"
|
||||||
|
copyright: "2025 henrygd"
|
||||||
|
homepage: "https://beszel.dev"
|
||||||
|
release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
|
||||||
|
publisher_support_url: "https://github.com/henrygd/beszel/issues"
|
||||||
|
short_description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
|
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||||
|
description: |
|
||||||
|
Beszel is a lightweight server monitoring platform that includes Docker
|
||||||
|
statistics, historical data, and alert functions. It has a friendly web
|
||||||
|
interface, simple configuration, and is ready to use out of the box.
|
||||||
|
It supports automatic backup, multi-user, OAuth authentication, and
|
||||||
|
API access.
|
||||||
|
tags:
|
||||||
|
- homelab
|
||||||
|
- monitoring
|
||||||
|
- self-hosted
|
||||||
|
repository:
|
||||||
|
owner: henrygd
|
||||||
|
name: beszel-winget
|
||||||
|
branch: henrygd.beszel-agent-{{ .Version }}
|
||||||
|
token: "{{ .Env.WINGET_TOKEN }}"
|
||||||
|
# pull_request:
|
||||||
|
# enabled: true
|
||||||
|
# draft: false
|
||||||
|
# base:
|
||||||
|
# owner: microsoft
|
||||||
|
# name: winget-pkgs
|
||||||
|
# branch: master
|
||||||
|
|
||||||
release:
|
release:
|
||||||
draft: true
|
draft: true
|
||||||
@@ -181,5 +233,5 @@ changelog:
|
|||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
exclude:
|
exclude:
|
||||||
- '^docs:'
|
- "^docs:"
|
||||||
- '^test:'
|
- "^test:"
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ ARCH ?= $(shell go env GOARCH)
|
|||||||
# Skip building the web UI if true
|
# Skip building the web UI if true
|
||||||
SKIP_WEB ?= false
|
SKIP_WEB ?= false
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
# Set executable extension based on target OS
|
||||||
|
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||||
|
|
||||||
|
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@@ -30,11 +33,29 @@ build-web-ui:
|
|||||||
npm run --prefix ./site build; \
|
npm run --prefix ./site build; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-agent: tidy
|
# Conditional .NET build - only for Windows
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
|
build-dotnet-conditional:
|
||||||
|
@if [ "$(OS)" = "windows" ]; then \
|
||||||
|
echo "Building .NET executable for Windows..."; \
|
||||||
|
if command -v dotnet >/dev/null 2>&1; then \
|
||||||
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
|
else \
|
||||||
|
echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update build-agent to include conditional .NET build
|
||||||
|
build-agent: tidy build-dotnet-conditional
|
||||||
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/agent
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
|
build-hub-dev: tidy
|
||||||
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
|
GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
build: build-agent build-hub
|
build: build-agent build-hub
|
||||||
|
|
||||||
@@ -47,18 +68,18 @@ generate-locales:
|
|||||||
dev-server: generate-locales
|
dev-server: generate-locales
|
||||||
cd ./site
|
cd ./site
|
||||||
@if command -v bun >/dev/null 2>&1; then \
|
@if command -v bun >/dev/null 2>&1; then \
|
||||||
cd ./site && bun run dev; \
|
cd ./site && bun run dev --host 0.0.0.0; \
|
||||||
else \
|
else \
|
||||||
cd ./site && npm run dev; \
|
cd ./site && npm run dev --host 0.0.0.0; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
dev-hub: export ENV=dev
|
dev-hub: export ENV=dev
|
||||||
dev-hub:
|
dev-hub:
|
||||||
mkdir -p ./site/dist && touch ./site/dist/index.html
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve"; \
|
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
|
||||||
else \
|
else \
|
||||||
cd ./cmd/hub && go run . serve; \
|
cd ./cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
dev-agent:
|
dev-agent:
|
||||||
@@ -67,6 +88,15 @@ dev-agent:
|
|||||||
else \
|
else \
|
||||||
go run beszel/cmd/agent; \
|
go run beszel/cmd/agent; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
build-dotnet:
|
||||||
|
@if command -v dotnet >/dev/null 2>&1; then \
|
||||||
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
|
else \
|
||||||
|
echo "dotnet not found"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# KEY="..." make -j dev
|
# KEY="..." make -j dev
|
||||||
dev: dev-server dev-hub dev-agent
|
dev: dev-server dev-hub dev-agent
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/agent"
|
"beszel/internal/agent"
|
||||||
"flag"
|
"beszel/internal/agent/health"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,47 +17,26 @@ import (
|
|||||||
type cmdOptions struct {
|
type cmdOptions struct {
|
||||||
key string // key is the public key(s) for SSH authentication.
|
key string // key is the public key(s) for SSH authentication.
|
||||||
listen string // listen is the address or port to listen on.
|
listen string // listen is the address or port to listen on.
|
||||||
|
// TODO: add hubURL and token
|
||||||
|
// hubURL string // hubURL is the URL of the hub to use.
|
||||||
|
// token string // token is the token to use for authentication.
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse parses the command line flags and populates the config struct.
|
// parse parses the command line flags and populates the config struct.
|
||||||
// It returns true if a subcommand was handled and the program should exit.
|
// It returns true if a subcommand was handled and the program should exit.
|
||||||
func (opts *cmdOptions) parse() bool {
|
func (opts *cmdOptions) parse() bool {
|
||||||
flag.StringVar(&opts.key, "key", "", "Public key(s) for SSH authentication")
|
|
||||||
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
|
|
||||||
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Printf("Usage: %s [command] [flags]\n", os.Args[0])
|
|
||||||
fmt.Println("\nCommands:")
|
|
||||||
fmt.Println(" health Check if the agent is running")
|
|
||||||
fmt.Println(" help Display this help message")
|
|
||||||
fmt.Println(" update Update to the latest version")
|
|
||||||
fmt.Println(" version Display the version")
|
|
||||||
fmt.Println("\nFlags:")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
subcommand := ""
|
subcommand := ""
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
subcommand = os.Args[1]
|
subcommand = os.Args[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subcommands that don't require any pflag parsing
|
||||||
switch subcommand {
|
switch subcommand {
|
||||||
case "-v", "version":
|
case "-v", "version":
|
||||||
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
||||||
return true
|
return true
|
||||||
case "help":
|
|
||||||
flag.Usage()
|
|
||||||
return true
|
|
||||||
case "update":
|
|
||||||
agent.Update()
|
|
||||||
return true
|
|
||||||
case "health":
|
case "health":
|
||||||
// for health, we need to parse flags first to get the listen address
|
err := health.Check()
|
||||||
args := append(os.Args[2:], subcommand)
|
|
||||||
flag.CommandLine.Parse(args)
|
|
||||||
addr := opts.getAddress()
|
|
||||||
network := agent.GetNetwork(addr)
|
|
||||||
err := agent.Health(addr, network)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -63,7 +44,57 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.Parse()
|
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
||||||
|
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
|
||||||
|
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
|
||||||
|
// pflag.StringVarP(&opts.hubURL, "hub-url", "u", "", "URL of the hub to use")
|
||||||
|
// pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
|
||||||
|
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
||||||
|
help := pflag.BoolP("help", "h", false, "Show this help message")
|
||||||
|
|
||||||
|
// Convert old single-dash long flags to double-dash for backward compatibility
|
||||||
|
flagsToConvert := []string{"key", "listen"}
|
||||||
|
for i, arg := range os.Args {
|
||||||
|
for _, flag := range flagsToConvert {
|
||||||
|
singleDash := "-" + flag
|
||||||
|
doubleDash := "--" + flag
|
||||||
|
if arg == singleDash {
|
||||||
|
os.Args[i] = doubleDash
|
||||||
|
break
|
||||||
|
} else if strings.HasPrefix(arg, singleDash+"=") {
|
||||||
|
os.Args[i] = doubleDash + arg[len(singleDash):]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pflag.Usage = func() {
|
||||||
|
builder := strings.Builder{}
|
||||||
|
builder.WriteString("Usage: ")
|
||||||
|
builder.WriteString(os.Args[0])
|
||||||
|
builder.WriteString(" [command] [flags]\n")
|
||||||
|
builder.WriteString("\nCommands:\n")
|
||||||
|
builder.WriteString(" health Check if the agent is running\n")
|
||||||
|
// builder.WriteString(" help Display this help message\n")
|
||||||
|
builder.WriteString(" update Update to the latest version\n")
|
||||||
|
builder.WriteString("\nFlags:\n")
|
||||||
|
fmt.Print(builder.String())
|
||||||
|
pflag.PrintDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse all arguments with pflag
|
||||||
|
pflag.Parse()
|
||||||
|
|
||||||
|
// Must run after pflag.Parse()
|
||||||
|
switch {
|
||||||
|
case *help || subcommand == "help":
|
||||||
|
pflag.Usage()
|
||||||
|
return true
|
||||||
|
case subcommand == "update":
|
||||||
|
agent.Update(*chinaMirrors)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +146,12 @@ func main() {
|
|||||||
serverConfig.Addr = addr
|
serverConfig.Addr = addr
|
||||||
serverConfig.Network = agent.GetNetwork(addr)
|
serverConfig.Network = agent.GetNetwork(addr)
|
||||||
|
|
||||||
agent := agent.NewAgent()
|
a, err := agent.NewAgent()
|
||||||
if err := agent.StartServer(serverConfig); err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to start server:", err)
|
log.Fatal("Failed to create agent: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Start(serverConfig); err != nil {
|
||||||
|
log.Fatal("Failed to start server: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"beszel/internal/agent"
|
"beszel/internal/agent"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"flag"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -245,7 +245,7 @@ func TestParseFlags(t *testing.T) {
|
|||||||
oldArgs := os.Args
|
oldArgs := os.Args
|
||||||
defer func() {
|
defer func() {
|
||||||
os.Args = oldArgs
|
os.Args = oldArgs
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -269,6 +269,22 @@ func TestParseFlags(t *testing.T) {
|
|||||||
listen: "",
|
listen: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "key flag double dash",
|
||||||
|
args: []string{"cmd", "--key", "testkey"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "testkey",
|
||||||
|
listen: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key flag short",
|
||||||
|
args: []string{"cmd", "-k", "testkey"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "testkey",
|
||||||
|
listen: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "addr flag only",
|
name: "addr flag only",
|
||||||
args: []string{"cmd", "-listen", ":8080"},
|
args: []string{"cmd", "-listen", ":8080"},
|
||||||
@@ -277,6 +293,22 @@ func TestParseFlags(t *testing.T) {
|
|||||||
listen: ":8080",
|
listen: ":8080",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "addr flag double dash",
|
||||||
|
args: []string{"cmd", "--listen", ":8080"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "",
|
||||||
|
listen: ":8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "addr flag short",
|
||||||
|
args: []string{"cmd", "-l", ":8080"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "",
|
||||||
|
listen: ":8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "both flags",
|
name: "both flags",
|
||||||
args: []string{"cmd", "-key", "testkey", "-listen", ":8080"},
|
args: []string{"cmd", "-key", "testkey", "-listen", ":8080"},
|
||||||
@@ -290,12 +322,12 @@ func TestParseFlags(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Reset flags for each test
|
// Reset flags for each test
|
||||||
flag.CommandLine = flag.NewFlagSet(tt.args[0], flag.ExitOnError)
|
pflag.CommandLine = pflag.NewFlagSet(tt.args[0], pflag.ExitOnError)
|
||||||
os.Args = tt.args
|
os.Args = tt.args
|
||||||
|
|
||||||
var opts cmdOptions
|
var opts cmdOptions
|
||||||
opts.parse()
|
opts.parse()
|
||||||
flag.Parse()
|
pflag.Parse()
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, opts)
|
assert.Equal(t, tt.expected, opts)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,11 +45,13 @@ func getBaseApp() *pocketbase.PocketBase {
|
|||||||
baseApp.RootCmd.Use = beszel.AppName
|
baseApp.RootCmd.Use = beszel.AppName
|
||||||
baseApp.RootCmd.Short = ""
|
baseApp.RootCmd.Short = ""
|
||||||
// add update command
|
// add update command
|
||||||
baseApp.RootCmd.AddCommand(&cobra.Command{
|
updateCmd := &cobra.Command{
|
||||||
Use: "update",
|
Use: "update",
|
||||||
Short: "Update " + beszel.AppName + " to the latest version",
|
Short: "Update " + beszel.AppName + " to the latest version",
|
||||||
Run: hub.Update,
|
Run: hub.Update,
|
||||||
})
|
}
|
||||||
|
updateCmd.Flags().Bool("china-mirrors", false, "Use mirror (gh.beszel.dev) instead of GitHub")
|
||||||
|
baseApp.RootCmd.AddCommand(updateCmd)
|
||||||
// add health command
|
// add health command
|
||||||
baseApp.RootCmd.AddCommand(newHealthCmd())
|
baseApp.RootCmd.AddCommand(newHealthCmd())
|
||||||
|
|
||||||
|
|||||||
26
beszel/dockerfile_agent
Normal file
26
beszel/dockerfile_agent
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
# RUN go mod download
|
||||||
|
COPY *.go ./
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
|
||||||
|
# Build
|
||||||
|
ARG TARGETOS TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||||
|
|
||||||
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
|
# --------------------------
|
||||||
|
# Final image: default scratch-based agent
|
||||||
|
# --------------------------
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
|
COPY --from=builder /tmp /tmp
|
||||||
|
|
||||||
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -12,9 +12,10 @@ COPY internal ./internal
|
|||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||||
|
|
||||||
# ? -------------------------
|
# --------------------------
|
||||||
FROM scratch
|
# Final image: GPU-enabled agent with nvidia-smi
|
||||||
|
# --------------------------
|
||||||
|
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
module beszel
|
module beszel
|
||||||
|
|
||||||
go 1.24.2
|
go 1.24.4
|
||||||
|
|
||||||
// lock shoutrrr to specific version to allow review before updating
|
// lock shoutrrr to specific version to allow review before updating
|
||||||
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
|
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
|
github.com/distatus/battery v0.11.0
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/goccy/go-json v0.10.5
|
github.com/google/uuid v1.6.0
|
||||||
github.com/nicholas-fedor/shoutrrr v0.8.8
|
github.com/lxzan/gws v1.8.9
|
||||||
|
github.com/nicholas-fedor/shoutrrr v0.8.17
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.27.1
|
github.com/pocketbase/pocketbase v0.29.3
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
github.com/shirou/gopsutil/v4 v4.25.6
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3
|
github.com/spf13/cast v1.9.2
|
||||||
github.com/spf13/cast v1.7.1
|
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/spf13/pflag v1.0.7
|
||||||
golang.org/x/crypto v0.37.0
|
github.com/stretchr/testify v1.11.0
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
|
golang.org/x/crypto v0.41.0
|
||||||
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,42 +30,40 @@ require (
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
|
github.com/dolthub/maphash v0.1.0 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.8.2 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.26.0 // indirect
|
golang.org/x/image v0.30.0 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/oauth2 v0.29.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
modernc.org/libc v1.64.0 // indirect
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||||
|
howett.net/plist v1.0.1 // indirect
|
||||||
|
modernc.org/libc v1.66.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.10.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.37.0 // indirect
|
modernc.org/sqlite v1.38.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
162
beszel/go.sum
162
beszel/go.sum
@@ -13,17 +13,22 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
|
||||||
|
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
|
||||||
|
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||||
|
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
@@ -42,41 +47,30 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
|
|||||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
|
|
||||||
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
|
|
||||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
|
||||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
|
||||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
|
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
|
||||||
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
|
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
@@ -85,11 +79,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
|||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.8.8 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8=
|
github.com/nicholas-fedor/shoutrrr v0.8.8 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c=
|
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c=
|
||||||
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
|
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||||
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
|
||||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -97,122 +88,105 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.27.1 h1:KGCsS8idUVTC5QHxTj91qHDhIXOb5Yb50wwHhNvJRTQ=
|
github.com/pocketbase/pocketbase v0.29.3 h1:Mj8o5awsbVJIdIoTuQNhfC2oL/c4aImQ3RyfFZlzFVg=
|
||||||
github.com/pocketbase/pocketbase v0.27.1/go.mod h1:aTpwwloVJzeJ7MlwTRrbI/x62QNR2/kkCrovmyrXpqs=
|
github.com/pocketbase/pocketbase v0.29.3/go.mod h1:oGpT67LObxCFK4V2fSL7J9YnPbBnnshOpJ5v3zcneww=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
|
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
|
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||||
|
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
|
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
|
||||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
|
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/libc v1.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -4,34 +4,55 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gliderlabs/ssh"
|
||||||
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
zfs bool // true if system has arcstats
|
zfs bool // true if system has arcstats
|
||||||
memCalc string // Memory calculation formula
|
memCalc string // Memory calculation formula
|
||||||
fsNames []string // List of filesystem device names being monitored
|
fsNames []string // List of filesystem device names being monitored
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info
|
systemInfo system.Info // Host system info
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *SessionCache // Cache for system stats based on primary session ID
|
cache *SessionCache // Cache for system stats based on primary session ID
|
||||||
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
|
server *ssh.Server // SSH server
|
||||||
|
dataDir string // Directory for persisting data
|
||||||
|
keys []gossh.PublicKey // SSH public keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgent() *Agent {
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
agent := &Agent{
|
// If the data directory is not set, it will attempt to find the optimal directory.
|
||||||
|
func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||||
|
agent = &Agent{
|
||||||
fsStats: make(map[string]*system.FsStats),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
cache: NewSessionCache(69 * time.Second),
|
cache: NewSessionCache(69 * time.Second),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent.dataDir, err = getDataDir(dataDir...)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Data directory not found")
|
||||||
|
} else {
|
||||||
|
slog.Info("Data directory", "path", agent.dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
agent.sensorConfig = agent.newSensorConfig()
|
agent.sensorConfig = agent.newSensorConfig()
|
||||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
@@ -49,10 +70,19 @@ func NewAgent() *Agent {
|
|||||||
|
|
||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
// initialize system info / docker manager
|
// initialize system info
|
||||||
agent.initializeSystemInfo()
|
agent.initializeSystemInfo()
|
||||||
|
|
||||||
|
// initialize connection manager
|
||||||
|
agent.connectionManager = newConnectionManager(agent)
|
||||||
|
|
||||||
|
// initialize disk info
|
||||||
agent.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
|
|
||||||
|
// initialize net io stats
|
||||||
agent.initializeNetIoStats()
|
agent.initializeNetIoStats()
|
||||||
|
|
||||||
|
// initialize docker manager
|
||||||
agent.dockerManager = newDockerManager(agent)
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
// initialize GPU manager
|
// initialize GPU manager
|
||||||
@@ -67,7 +97,7 @@ func NewAgent() *Agent {
|
|||||||
slog.Debug("Stats", "data", agent.gatherStats(""))
|
slog.Debug("Stats", "data", agent.gatherStats(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent
|
return agent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
||||||
@@ -83,35 +113,70 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
|||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
|
|
||||||
cachedData, ok := a.cache.Get(sessionID)
|
data, isCached := a.cache.Get(sessionID)
|
||||||
if ok {
|
if isCached {
|
||||||
slog.Debug("Cached stats", "session", sessionID)
|
slog.Debug("Cached data", "session", sessionID)
|
||||||
return cachedData
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
*cachedData = system.CombinedData{
|
*data = system.CombinedData{
|
||||||
Stats: a.getSystemStats(),
|
Stats: a.getSystemStats(),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
slog.Debug("System stats", "data", cachedData)
|
slog.Debug("System data", "data", data)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
cachedData.Containers = containerStats
|
data.Containers = containerStats
|
||||||
slog.Debug("Docker stats", "data", cachedData.Containers)
|
slog.Debug("Containers", "data", data.Containers)
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("Docker stats", "err", err)
|
slog.Debug("Containers", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
cachedData.Stats.ExtraFs[name] = stats
|
data.Stats.ExtraFs[name] = stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
a.cache.Set(sessionID, cachedData)
|
a.cache.Set(sessionID, data)
|
||||||
return cachedData
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAgent initializes and starts the agent with optional WebSocket connection
|
||||||
|
func (a *Agent) Start(serverOptions ServerOptions) error {
|
||||||
|
a.keys = serverOptions.Keys
|
||||||
|
return a.connectionManager.Start(serverOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) getFingerprint() string {
|
||||||
|
// first look for a fingerprint in the data directory
|
||||||
|
if a.dataDir != "" {
|
||||||
|
if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil {
|
||||||
|
return string(fp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no fingerprint is found, generate one
|
||||||
|
fingerprint, err := host.HostID()
|
||||||
|
if err != nil || fingerprint == "" {
|
||||||
|
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// hash fingerprint
|
||||||
|
sum := sha256.Sum256([]byte(fingerprint))
|
||||||
|
fingerprint = hex.EncodeToString(sum[:24])
|
||||||
|
|
||||||
|
// save fingerprint to data directory
|
||||||
|
if a.dataDir != "" {
|
||||||
|
err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to save fingerprint", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fingerprint
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSessionCache_GetSet(t *testing.T) {
|
func TestSessionCache_GetSet(t *testing.T) {
|
||||||
synctest.Run(func() {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
cache := NewSessionCache(69 * time.Second)
|
cache := NewSessionCache(69 * time.Second)
|
||||||
|
|
||||||
testData := &system.CombinedData{
|
testData := &system.CombinedData{
|
||||||
|
|||||||
9
beszel/internal/agent/agent_test_helpers.go
Normal file
9
beszel/internal/agent/agent_test_helpers.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
// TESTING ONLY: GetConnectionManager is a helper function to get the connection manager for testing.
|
||||||
|
func (a *Agent) GetConnectionManager() *ConnectionManager {
|
||||||
|
return a.connectionManager
|
||||||
|
}
|
||||||
53
beszel/internal/agent/battery/battery.go
Normal file
53
beszel/internal/agent/battery/battery.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//go:build !freebsd
|
||||||
|
|
||||||
|
// Package battery provides functions to check if the system has a battery and to get the battery stats.
|
||||||
|
package battery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/distatus/battery"
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemHasBattery = false
|
||||||
|
var haveCheckedBattery = false
|
||||||
|
|
||||||
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
|
func HasReadableBattery() bool {
|
||||||
|
if haveCheckedBattery {
|
||||||
|
return systemHasBattery
|
||||||
|
}
|
||||||
|
haveCheckedBattery = true
|
||||||
|
bat, err := battery.Get(0)
|
||||||
|
if err == nil && bat != nil {
|
||||||
|
systemHasBattery = true
|
||||||
|
} else {
|
||||||
|
slog.Debug("No battery found", "err", err)
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBatteryStats returns the current battery percent and charge state
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !systemHasBattery {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
batteries, err := battery.GetAll()
|
||||||
|
if err != nil || len(batteries) == 0 {
|
||||||
|
return batteryPercent, batteryState, err
|
||||||
|
}
|
||||||
|
totalCapacity := float64(0)
|
||||||
|
totalCharge := float64(0)
|
||||||
|
for _, bat := range batteries {
|
||||||
|
if bat.Design != 0 {
|
||||||
|
totalCapacity += bat.Design
|
||||||
|
} else {
|
||||||
|
totalCapacity += bat.Full
|
||||||
|
}
|
||||||
|
totalCharge += bat.Current
|
||||||
|
}
|
||||||
|
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||||
|
batteryState = uint8(batteries[0].State.Raw)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
|
}
|
||||||
13
beszel/internal/agent/battery/battery_freebsd.go
Normal file
13
beszel/internal/agent/battery/battery_freebsd.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build freebsd
|
||||||
|
|
||||||
|
package battery
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func HasReadableBattery() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBatteryStats() (uint8, uint8, error) {
|
||||||
|
return 0, 0, errors.ErrUnsupported
|
||||||
|
}
|
||||||
265
beszel/internal/agent/client.go
Normal file
265
beszel/internal/agent/client.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsDeadline = 70 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebSocketClient manages the WebSocket connection between the agent and hub.
|
||||||
|
// It handles authentication, message routing, and connection lifecycle management.
|
||||||
|
type WebSocketClient struct {
|
||||||
|
gws.BuiltinEventHandler
|
||||||
|
options *gws.ClientOption // WebSocket client configuration options
|
||||||
|
agent *Agent // Reference to the parent agent
|
||||||
|
Conn *gws.Conn // Active WebSocket connection
|
||||||
|
hubURL *url.URL // Parsed hub URL for connection
|
||||||
|
token string // Authentication token for hub registration
|
||||||
|
fingerprint string // System fingerprint for identification
|
||||||
|
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
|
||||||
|
lastConnectAttempt time.Time // Timestamp of last connection attempt
|
||||||
|
hubVerified bool // Whether the hub has been cryptographically verified
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWebSocketClient creates a new WebSocket client for the given agent.
|
||||||
|
// It reads configuration from environment variables and validates the hub URL.
|
||||||
|
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||||
|
hubURLStr, exists := GetEnv("HUB_URL")
|
||||||
|
if !exists {
|
||||||
|
return nil, errors.New("HUB_URL environment variable not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
client = &WebSocketClient{}
|
||||||
|
|
||||||
|
client.hubURL, err = url.Parse(hubURLStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid hub URL")
|
||||||
|
}
|
||||||
|
// get registration token
|
||||||
|
client.token, err = getToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.agent = agent
|
||||||
|
client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
|
||||||
|
client.fingerprint = agent.getFingerprint()
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getToken returns the token for the WebSocket client.
|
||||||
|
// It first checks the TOKEN environment variable, then the TOKEN_FILE environment variable.
|
||||||
|
// If neither is set, it returns an error.
|
||||||
|
func getToken() (string, error) {
|
||||||
|
// get token from env var
|
||||||
|
token, _ := GetEnv("TOKEN")
|
||||||
|
if token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
// get token from file
|
||||||
|
tokenFile, _ := GetEnv("TOKEN_FILE")
|
||||||
|
if tokenFile == "" {
|
||||||
|
return "", errors.New("must set TOKEN or TOKEN_FILE")
|
||||||
|
}
|
||||||
|
tokenBytes, err := os.ReadFile(tokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(tokenBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
|
// It configures the connection URL, TLS settings, and authentication headers.
|
||||||
|
func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
||||||
|
if client.options != nil {
|
||||||
|
return client.options
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the hub url to use websocket scheme and api path
|
||||||
|
if client.hubURL.Scheme == "https" {
|
||||||
|
client.hubURL.Scheme = "wss"
|
||||||
|
} else {
|
||||||
|
client.hubURL.Scheme = "ws"
|
||||||
|
}
|
||||||
|
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
||||||
|
|
||||||
|
client.options = &gws.ClientOption{
|
||||||
|
Addr: client.hubURL.String(),
|
||||||
|
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
RequestHeader: http.Header{
|
||||||
|
"User-Agent": []string{getUserAgent()},
|
||||||
|
"X-Token": []string{client.token},
|
||||||
|
"X-Beszel": []string{beszel.Version},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return client.options
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect establishes a WebSocket connection to the hub.
|
||||||
|
// It closes any existing connection before attempting to reconnect.
|
||||||
|
func (client *WebSocketClient) Connect() (err error) {
|
||||||
|
client.lastConnectAttempt = time.Now()
|
||||||
|
|
||||||
|
// make sure previous connection is closed
|
||||||
|
client.Close()
|
||||||
|
|
||||||
|
client.Conn, _, err = gws.NewClient(client, client.getOptions())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go client.Conn.ReadLoop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnOpen handles WebSocket connection establishment.
|
||||||
|
// It sets a deadline for the connection to prevent hanging.
|
||||||
|
func (client *WebSocketClient) OnOpen(conn *gws.Conn) {
|
||||||
|
conn.SetDeadline(time.Now().Add(wsDeadline))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnClose handles WebSocket connection closure.
|
||||||
|
// It logs the closure reason and notifies the connection manager.
|
||||||
|
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
|
||||||
|
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
|
||||||
|
client.agent.connectionManager.eventChan <- WebSocketDisconnect
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage handles incoming WebSocket messages from the hub.
|
||||||
|
// It decodes CBOR messages and routes them to appropriate handlers.
|
||||||
|
func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) {
|
||||||
|
defer message.Close()
|
||||||
|
conn.SetDeadline(time.Now().Add(wsDeadline))
|
||||||
|
|
||||||
|
if message.Opcode != gws.OpcodeBinary {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cbor.NewDecoder(message.Data).Decode(client.hubRequest); err != nil {
|
||||||
|
slog.Error("Error parsing message", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.handleHubRequest(client.hubRequest); err != nil {
|
||||||
|
slog.Error("Error handling message", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnPing handles WebSocket ping frames.
|
||||||
|
// It responds with a pong and updates the connection deadline.
|
||||||
|
func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) {
|
||||||
|
conn.SetDeadline(time.Now().Add(wsDeadline))
|
||||||
|
conn.WritePong(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint.
|
||||||
|
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage]) (err error) {
|
||||||
|
var authRequest common.FingerprintRequest
|
||||||
|
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.verifySignature(authRequest.Signature); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.hubVerified = true
|
||||||
|
client.agent.connectionManager.eventChan <- WebSocketConnect
|
||||||
|
|
||||||
|
response := &common.FingerprintResponse{
|
||||||
|
Fingerprint: client.fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
if authRequest.NeedSysInfo {
|
||||||
|
response.Hostname = client.agent.systemInfo.Hostname
|
||||||
|
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
||||||
|
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.sendMessage(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySignature verifies the signature of the token using the public keys.
|
||||||
|
func (client *WebSocketClient) verifySignature(signature []byte) (err error) {
|
||||||
|
for _, pubKey := range client.agent.keys {
|
||||||
|
sig := ssh.Signature{
|
||||||
|
Format: pubKey.Type(),
|
||||||
|
Blob: signature,
|
||||||
|
}
|
||||||
|
if err = pubKey.Verify([]byte(client.token), &sig); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("invalid signature - check KEY value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the WebSocket connection gracefully.
|
||||||
|
// This method is safe to call multiple times.
|
||||||
|
func (client *WebSocketClient) Close() {
|
||||||
|
if client.Conn != nil {
|
||||||
|
_ = client.Conn.WriteClose(1000, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHubRequest routes the request to the appropriate handler.
|
||||||
|
// It ensures the hub is verified before processing most requests.
|
||||||
|
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error {
|
||||||
|
if !client.hubVerified && msg.Action != common.CheckFingerprint {
|
||||||
|
return errors.New("hub not verified")
|
||||||
|
}
|
||||||
|
switch msg.Action {
|
||||||
|
case common.GetData:
|
||||||
|
return client.sendSystemData()
|
||||||
|
case common.CheckFingerprint:
|
||||||
|
return client.handleAuthChallenge(msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSystemData gathers and sends current system statistics to the hub.
|
||||||
|
func (client *WebSocketClient) sendSystemData() error {
|
||||||
|
sysStats := client.agent.gatherStats(client.token)
|
||||||
|
return client.sendMessage(sysStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
|
||||||
|
func (client *WebSocketClient) sendMessage(data any) error {
|
||||||
|
bytes, err := cbor.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserAgent returns one of two User-Agent strings based on current time.
|
||||||
|
// This is used to avoid being blocked by Cloudflare or other anti-bot measures.
|
||||||
|
func getUserAgent() string {
|
||||||
|
const (
|
||||||
|
uaBase = "Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
uaWindows = "Windows NT 11.0; Win64; x64"
|
||||||
|
uaMac = "Macintosh; Intel Mac OS X 14_0_0"
|
||||||
|
)
|
||||||
|
if time.Now().UnixNano()%2 == 0 {
|
||||||
|
return fmt.Sprintf(uaBase, uaWindows)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(uaBase, uaMac)
|
||||||
|
}
|
||||||
538
beszel/internal/agent/client_test.go
Normal file
538
beszel/internal/agent/client_test.go
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewWebSocketClient tests WebSocket client creation
|
||||||
|
func TestNewWebSocketClient(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
hubURL string
|
||||||
|
token string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid configuration",
|
||||||
|
hubURL: "http://localhost:8080",
|
||||||
|
token: "test-token-123",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid https URL",
|
||||||
|
hubURL: "https://hub.example.com",
|
||||||
|
token: "secure-token",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing hub URL",
|
||||||
|
hubURL: "",
|
||||||
|
token: "test-token",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "HUB_URL environment variable not set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid URL",
|
||||||
|
hubURL: "ht\ttp://invalid",
|
||||||
|
token: "test-token",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "invalid hub URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing token",
|
||||||
|
hubURL: "http://localhost:8080",
|
||||||
|
token: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "must set TOKEN or TOKEN_FILE",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up environment
|
||||||
|
if tc.hubURL != "" {
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
}
|
||||||
|
if tc.token != "" {
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if err != nil && tc.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
assert.Nil(t, client)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, client)
|
||||||
|
assert.Equal(t, agent, client.agent)
|
||||||
|
assert.Equal(t, tc.token, client.token)
|
||||||
|
assert.Equal(t, tc.hubURL, client.hubURL.String())
|
||||||
|
assert.NotEmpty(t, client.fingerprint)
|
||||||
|
assert.NotNil(t, client.hubRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_GetOptions tests WebSocket client options configuration
|
||||||
|
func TestWebSocketClient_GetOptions(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
inputURL string
|
||||||
|
expectedScheme string
|
||||||
|
expectedPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http to ws conversion",
|
||||||
|
inputURL: "http://localhost:8080",
|
||||||
|
expectedScheme: "ws",
|
||||||
|
expectedPath: "/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https to wss conversion",
|
||||||
|
inputURL: "https://hub.example.com",
|
||||||
|
expectedScheme: "wss",
|
||||||
|
expectedPath: "/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing path preservation",
|
||||||
|
inputURL: "http://localhost:8080/custom/path",
|
||||||
|
expectedScheme: "ws",
|
||||||
|
expectedPath: "/custom/path/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
options := client.getOptions()
|
||||||
|
|
||||||
|
// Parse the WebSocket URL
|
||||||
|
wsURL, err := url.Parse(options.Addr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.expectedScheme, wsURL.Scheme)
|
||||||
|
assert.Equal(t, tc.expectedPath, wsURL.Path)
|
||||||
|
|
||||||
|
// Check headers
|
||||||
|
assert.Equal(t, "test-token", options.RequestHeader.Get("X-Token"))
|
||||||
|
assert.Equal(t, beszel.Version, options.RequestHeader.Get("X-Beszel"))
|
||||||
|
assert.Contains(t, options.RequestHeader.Get("User-Agent"), "Mozilla/5.0")
|
||||||
|
|
||||||
|
// Test options caching
|
||||||
|
options2 := client.getOptions()
|
||||||
|
assert.Same(t, options, options2, "Options should be cached")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_VerifySignature tests signature verification
|
||||||
|
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Generate test key pairs
|
||||||
|
_, goodPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
goodPubKey, err := ssh.NewPublicKey(goodPrivKey.Public().(ed25519.PublicKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, badPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
keys []ssh.PublicKey
|
||||||
|
token string
|
||||||
|
signWith ed25519.PrivateKey
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid signature with correct key",
|
||||||
|
keys: []ssh.PublicKey{goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid signature with wrong key",
|
||||||
|
keys: []ssh.PublicKey{goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: badPrivKey,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid signature with multiple keys",
|
||||||
|
keys: []ssh.PublicKey{badPubKey, goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no valid keys",
|
||||||
|
keys: []ssh.PublicKey{badPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up agent with test keys
|
||||||
|
agent.keys = tc.keys
|
||||||
|
client.token = tc.token
|
||||||
|
|
||||||
|
// Create signature
|
||||||
|
signature := ed25519.Sign(tc.signWith, []byte(tc.token))
|
||||||
|
|
||||||
|
err := client.verifySignature(signature)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid signature")
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_HandleHubRequest tests hub request routing (basic verification logic)
|
||||||
|
func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
action common.WebSocketAction
|
||||||
|
hubVerified bool
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CheckFingerprint without verification",
|
||||||
|
action: common.CheckFingerprint,
|
||||||
|
hubVerified: false,
|
||||||
|
expectError: false, // CheckFingerprint is allowed without verification
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetData without verification",
|
||||||
|
action: common.GetData,
|
||||||
|
hubVerified: false,
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "hub not verified",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client.hubVerified = tc.hubVerified
|
||||||
|
|
||||||
|
// Create minimal request
|
||||||
|
hubRequest := &common.HubRequest[cbor.RawMessage]{
|
||||||
|
Action: tc.action,
|
||||||
|
Data: cbor.RawMessage{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.handleHubRequest(hubRequest)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tc.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For CheckFingerprint, we expect a decode error since we're not providing valid data,
|
||||||
|
// but it shouldn't be the "hub not verified" error
|
||||||
|
if err != nil && tc.errorMsg != "" {
|
||||||
|
assert.NotContains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_GetUserAgent tests user agent generation
|
||||||
|
func TestGetUserAgent(t *testing.T) {
|
||||||
|
// Run multiple times to check both variants
|
||||||
|
userAgents := make(map[string]bool)
|
||||||
|
|
||||||
|
for range 20 {
|
||||||
|
ua := getUserAgent()
|
||||||
|
userAgents[ua] = true
|
||||||
|
|
||||||
|
// Check that it's a valid Mozilla user agent
|
||||||
|
assert.Contains(t, ua, "Mozilla/5.0")
|
||||||
|
assert.Contains(t, ua, "AppleWebKit/537.36")
|
||||||
|
assert.Contains(t, ua, "Chrome/124.0.0.0")
|
||||||
|
assert.Contains(t, ua, "Safari/537.36")
|
||||||
|
|
||||||
|
// Should contain either Windows or Mac
|
||||||
|
isWindows := strings.Contains(ua, "Windows NT 11.0")
|
||||||
|
isMac := strings.Contains(ua, "Macintosh; Intel Mac OS X 14_0_0")
|
||||||
|
assert.True(t, isWindows || isMac, "User agent should contain either Windows or Mac identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With enough iterations, we should see both variants
|
||||||
|
// though this might occasionally fail
|
||||||
|
if len(userAgents) == 1 {
|
||||||
|
t.Log("Note: Only one user agent variant was generated in this test run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_Close tests connection closing
|
||||||
|
func TestWebSocketClient_Close(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test closing with nil connection (should not panic)
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
client.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_ConnectRateLimit tests connection rate limiting
|
||||||
|
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set recent connection attempt
|
||||||
|
client.lastConnectAttempt = time.Now()
|
||||||
|
|
||||||
|
// Test that connection fails quickly due to rate limiting
|
||||||
|
// This won't actually connect but should fail fast
|
||||||
|
err = client.Connect()
|
||||||
|
assert.Error(t, err, "Connection should fail but not hang")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetToken tests the getToken function with various scenarios
|
||||||
|
func TestGetToken(t *testing.T) {
|
||||||
|
unsetEnvVars := func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
os.Unsetenv("TOKEN")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||||
|
os.Unsetenv("TOKEN_FILE")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Set TOKEN env var
|
||||||
|
expectedToken := "test-token-from-env"
|
||||||
|
os.Setenv("TOKEN", expectedToken)
|
||||||
|
defer os.Unsetenv("TOKEN")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
|
||||||
|
expectedToken := "test-token-from-beszel-env"
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create a temporary token file
|
||||||
|
expectedToken := "test-token-from-file"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(expectedToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set TOKEN_FILE env var
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create a temporary token file
|
||||||
|
expectedToken := "test-token-from-beszel-file"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(expectedToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create a temporary token file
|
||||||
|
fileToken := "token-from-file"
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
|
||||||
|
_, err = tokenFile.WriteString(fileToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set both TOKEN and TOKEN_FILE
|
||||||
|
envToken := "token-from-env"
|
||||||
|
os.Setenv("TOKEN", envToken)
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("TOKEN")
|
||||||
|
os.Unsetenv("TOKEN_FILE")
|
||||||
|
}()
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, envToken, token, "TOKEN should take precedence over TOKEN_FILE")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "", token)
|
||||||
|
assert.Contains(t, err.Error(), "must set TOKEN or TOKEN_FILE")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Set TOKEN_FILE to a non-existent file
|
||||||
|
os.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "", token)
|
||||||
|
assert.Contains(t, err.Error(), "no such file or directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles empty token file", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
|
// Create an empty token file
|
||||||
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.Remove(tokenFile.Name())
|
||||||
|
tokenFile.Close()
|
||||||
|
|
||||||
|
// Set TOKEN_FILE env var
|
||||||
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "", token, "Empty file should return empty string")
|
||||||
|
})
|
||||||
|
}
|
||||||
220
beszel/internal/agent/connection_manager.go
Normal file
220
beszel/internal/agent/connection_manager.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/agent/health"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionManager manages the connection state and events for the agent.
|
||||||
|
// It handles both WebSocket and SSH connections, automatically switching between
|
||||||
|
// them based on availability and managing reconnection attempts.
|
||||||
|
type ConnectionManager struct {
|
||||||
|
agent *Agent // Reference to the parent agent
|
||||||
|
State ConnectionState // Current connection state
|
||||||
|
eventChan chan ConnectionEvent // Channel for connection events
|
||||||
|
wsClient *WebSocketClient // WebSocket client for hub communication
|
||||||
|
serverOptions ServerOptions // Configuration for SSH server
|
||||||
|
wsTicker *time.Ticker // Ticker for WebSocket connection attempts
|
||||||
|
isConnecting bool // Prevents multiple simultaneous reconnection attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionState represents the current connection state of the agent.
|
||||||
|
type ConnectionState uint8
|
||||||
|
|
||||||
|
// ConnectionEvent represents connection-related events that can occur.
|
||||||
|
type ConnectionEvent uint8
|
||||||
|
|
||||||
|
// Connection states
|
||||||
|
const (
|
||||||
|
Disconnected ConnectionState = iota // No active connection
|
||||||
|
WebSocketConnected // Connected via WebSocket
|
||||||
|
SSHConnected // Connected via SSH
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connection events
|
||||||
|
const (
|
||||||
|
WebSocketConnect ConnectionEvent = iota // WebSocket connection established
|
||||||
|
WebSocketDisconnect // WebSocket connection lost
|
||||||
|
SSHConnect // SSH connection established
|
||||||
|
SSHDisconnect // SSH connection lost
|
||||||
|
)
|
||||||
|
|
||||||
|
const wsTickerInterval = 10 * time.Second
|
||||||
|
|
||||||
|
// newConnectionManager creates a new connection manager for the given agent.
|
||||||
|
func newConnectionManager(agent *Agent) *ConnectionManager {
|
||||||
|
cm := &ConnectionManager{
|
||||||
|
agent: agent,
|
||||||
|
State: Disconnected,
|
||||||
|
}
|
||||||
|
return cm
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWsTicker starts or resets the WebSocket connection attempt ticker.
|
||||||
|
func (c *ConnectionManager) startWsTicker() {
|
||||||
|
if c.wsTicker == nil {
|
||||||
|
c.wsTicker = time.NewTicker(wsTickerInterval)
|
||||||
|
} else {
|
||||||
|
c.wsTicker.Reset(wsTickerInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopWsTicker stops the WebSocket connection attempt ticker.
|
||||||
|
func (c *ConnectionManager) stopWsTicker() {
|
||||||
|
if c.wsTicker != nil {
|
||||||
|
c.wsTicker.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins connection attempts and enters the main event loop.
|
||||||
|
// It handles connection events, periodic health updates, and graceful shutdown.
|
||||||
|
func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||||
|
if c.eventChan != nil {
|
||||||
|
return errors.New("already started")
|
||||||
|
}
|
||||||
|
|
||||||
|
wsClient, err := newWebSocketClient(c.agent)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Error creating WebSocket client", "err", err)
|
||||||
|
}
|
||||||
|
c.wsClient = wsClient
|
||||||
|
|
||||||
|
c.serverOptions = serverOptions
|
||||||
|
c.eventChan = make(chan ConnectionEvent, 1)
|
||||||
|
|
||||||
|
// signal handling for shutdown
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
c.startWsTicker()
|
||||||
|
c.connect()
|
||||||
|
|
||||||
|
// update health status immediately and every 90 seconds
|
||||||
|
_ = health.Update()
|
||||||
|
healthTicker := time.Tick(90 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case connectionEvent := <-c.eventChan:
|
||||||
|
c.handleEvent(connectionEvent)
|
||||||
|
case <-c.wsTicker.C:
|
||||||
|
_ = c.startWebSocketConnection()
|
||||||
|
case <-healthTicker:
|
||||||
|
_ = health.Update()
|
||||||
|
case <-sigChan:
|
||||||
|
slog.Info("Shutting down")
|
||||||
|
_ = c.agent.StopServer()
|
||||||
|
c.closeWebSocket()
|
||||||
|
return health.CleanUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEvent processes connection events and updates the connection state accordingly.
|
||||||
|
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
||||||
|
switch event {
|
||||||
|
case WebSocketConnect:
|
||||||
|
c.handleStateChange(WebSocketConnected)
|
||||||
|
case SSHConnect:
|
||||||
|
c.handleStateChange(SSHConnected)
|
||||||
|
case WebSocketDisconnect:
|
||||||
|
if c.State == WebSocketConnected {
|
||||||
|
c.handleStateChange(Disconnected)
|
||||||
|
}
|
||||||
|
case SSHDisconnect:
|
||||||
|
if c.State == SSHConnected {
|
||||||
|
c.handleStateChange(Disconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStateChange updates the connection state and performs necessary actions
|
||||||
|
// based on the new state, including stopping services and initiating reconnections.
|
||||||
|
func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
|
||||||
|
if c.State == newState {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.State = newState
|
||||||
|
switch newState {
|
||||||
|
case WebSocketConnected:
|
||||||
|
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
||||||
|
c.stopWsTicker()
|
||||||
|
_ = c.agent.StopServer()
|
||||||
|
c.isConnecting = false
|
||||||
|
case SSHConnected:
|
||||||
|
// stop new ws connection attempts
|
||||||
|
slog.Info("SSH connection established")
|
||||||
|
c.stopWsTicker()
|
||||||
|
c.isConnecting = false
|
||||||
|
case Disconnected:
|
||||||
|
if c.isConnecting {
|
||||||
|
// Already handling reconnection, avoid duplicate attempts
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.isConnecting = true
|
||||||
|
slog.Warn("Disconnected from hub")
|
||||||
|
// make sure old ws connection is closed
|
||||||
|
c.closeWebSocket()
|
||||||
|
// reconnect
|
||||||
|
go c.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect handles the connection logic with proper delays and priority.
|
||||||
|
// It attempts WebSocket connection first, falling back to SSH server if needed.
|
||||||
|
func (c *ConnectionManager) connect() {
|
||||||
|
c.isConnecting = true
|
||||||
|
defer func() {
|
||||||
|
c.isConnecting = false
|
||||||
|
}()
|
||||||
|
|
||||||
|
if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try WebSocket first, if it fails, start SSH server
|
||||||
|
err := c.startWebSocketConnection()
|
||||||
|
if err != nil && c.State == Disconnected {
|
||||||
|
c.startSSHServer()
|
||||||
|
c.startWsTicker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
|
||||||
|
func (c *ConnectionManager) startWebSocketConnection() error {
|
||||||
|
if c.State != Disconnected {
|
||||||
|
return errors.New("already connected")
|
||||||
|
}
|
||||||
|
if c.wsClient == nil {
|
||||||
|
return errors.New("WebSocket client not initialized")
|
||||||
|
}
|
||||||
|
if time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
|
||||||
|
return errors.New("already connecting")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.wsClient.Connect()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("WebSocket connection failed", "err", err)
|
||||||
|
c.closeWebSocket()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSSHServer starts the SSH server if the agent is currently disconnected.
|
||||||
|
func (c *ConnectionManager) startSSHServer() {
|
||||||
|
if c.State == Disconnected {
|
||||||
|
go c.agent.StartServer(c.serverOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeWebSocket closes the WebSocket connection if it exists.
|
||||||
|
func (c *ConnectionManager) closeWebSocket() {
|
||||||
|
if c.wsClient != nil {
|
||||||
|
c.wsClient.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
315
beszel/internal/agent/connection_manager_test.go
Normal file
315
beszel/internal/agent/connection_manager_test.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createTestAgent(t *testing.T) *Agent {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
agent, err := NewAgent(dataDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestServerOptions(t *testing.T) ServerOptions {
|
||||||
|
// Generate test key pair
|
||||||
|
_, privKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
sshPubKey, err := ssh.NewPublicKey(privKey.Public().(ed25519.PublicKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Find available port
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
port := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
listener.Close()
|
||||||
|
|
||||||
|
return ServerOptions{
|
||||||
|
Network: "tcp",
|
||||||
|
Addr: fmt.Sprintf("127.0.0.1:%d", port),
|
||||||
|
Keys: []ssh.PublicKey{sshPubKey},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_NewConnectionManager tests connection manager creation
|
||||||
|
func TestConnectionManager_NewConnectionManager(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := newConnectionManager(agent)
|
||||||
|
|
||||||
|
assert.NotNil(t, cm, "Connection manager should not be nil")
|
||||||
|
assert.Equal(t, agent, cm.agent, "Agent reference should be set")
|
||||||
|
assert.Equal(t, Disconnected, cm.State, "Initial state should be Disconnected")
|
||||||
|
assert.Nil(t, cm.eventChan, "Event channel should be nil initially")
|
||||||
|
assert.Nil(t, cm.wsClient, "WebSocket client should be nil initially")
|
||||||
|
assert.Nil(t, cm.wsTicker, "WebSocket ticker should be nil initially")
|
||||||
|
assert.False(t, cm.isConnecting, "isConnecting should be false initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_StateTransitions tests basic state transitions
|
||||||
|
func TestConnectionManager_StateTransitions(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
initialState := cm.State
|
||||||
|
cm.wsClient = &WebSocketClient{
|
||||||
|
hubURL: &url.URL{
|
||||||
|
Host: "localhost:8080",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.NotNil(t, cm, "Connection manager should not be nil")
|
||||||
|
assert.Equal(t, Disconnected, initialState, "Initial state should be Disconnected")
|
||||||
|
|
||||||
|
// Test state transitions
|
||||||
|
cm.handleStateChange(WebSocketConnected)
|
||||||
|
assert.Equal(t, WebSocketConnected, cm.State, "State should change to WebSocketConnected")
|
||||||
|
|
||||||
|
cm.handleStateChange(SSHConnected)
|
||||||
|
assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected")
|
||||||
|
|
||||||
|
cm.handleStateChange(Disconnected)
|
||||||
|
assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected")
|
||||||
|
|
||||||
|
// Test that same state doesn't trigger changes
|
||||||
|
cm.State = WebSocketConnected
|
||||||
|
cm.handleStateChange(WebSocketConnected)
|
||||||
|
assert.Equal(t, WebSocketConnected, cm.State, "Same state should not trigger change")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_EventHandling tests event handling logic
|
||||||
|
func TestConnectionManager_EventHandling(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
cm.wsClient = &WebSocketClient{
|
||||||
|
hubURL: &url.URL{
|
||||||
|
Host: "localhost:8080",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
initialState ConnectionState
|
||||||
|
event ConnectionEvent
|
||||||
|
expectedState ConnectionState
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "WebSocket connect from disconnected",
|
||||||
|
initialState: Disconnected,
|
||||||
|
event: WebSocketConnect,
|
||||||
|
expectedState: WebSocketConnected,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH connect from disconnected",
|
||||||
|
initialState: Disconnected,
|
||||||
|
event: SSHConnect,
|
||||||
|
expectedState: SSHConnected,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "WebSocket disconnect from connected",
|
||||||
|
initialState: WebSocketConnected,
|
||||||
|
event: WebSocketDisconnect,
|
||||||
|
expectedState: Disconnected,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH disconnect from connected",
|
||||||
|
initialState: SSHConnected,
|
||||||
|
event: SSHDisconnect,
|
||||||
|
expectedState: Disconnected,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "WebSocket disconnect from SSH connected (no change)",
|
||||||
|
initialState: SSHConnected,
|
||||||
|
event: WebSocketDisconnect,
|
||||||
|
expectedState: SSHConnected,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH disconnect from WebSocket connected (no change)",
|
||||||
|
initialState: WebSocketConnected,
|
||||||
|
event: SSHDisconnect,
|
||||||
|
expectedState: WebSocketConnected,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cm.State = tc.initialState
|
||||||
|
cm.handleEvent(tc.event)
|
||||||
|
assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_TickerManagement tests WebSocket ticker management
|
||||||
|
func TestConnectionManager_TickerManagement(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
|
||||||
|
// Test starting ticker
|
||||||
|
cm.startWsTicker()
|
||||||
|
assert.NotNil(t, cm.wsTicker, "Ticker should be created")
|
||||||
|
|
||||||
|
// Test stopping ticker (should not panic)
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
cm.stopWsTicker()
|
||||||
|
}, "Stopping ticker should not panic")
|
||||||
|
|
||||||
|
// Test stopping nil ticker (should not panic)
|
||||||
|
cm.wsTicker = nil
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
cm.stopWsTicker()
|
||||||
|
}, "Stopping nil ticker should not panic")
|
||||||
|
|
||||||
|
// Test restarting ticker
|
||||||
|
cm.startWsTicker()
|
||||||
|
assert.NotNil(t, cm.wsTicker, "Ticker should be recreated")
|
||||||
|
|
||||||
|
// Test resetting existing ticker
|
||||||
|
firstTicker := cm.wsTicker
|
||||||
|
cm.startWsTicker()
|
||||||
|
assert.Equal(t, firstTicker, cm.wsTicker, "Same ticker instance should be reused")
|
||||||
|
|
||||||
|
cm.stopWsTicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
|
||||||
|
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping WebSocket connection test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
|
||||||
|
// Test WebSocket connection without proper environment
|
||||||
|
err := cm.startWebSocketConnection()
|
||||||
|
assert.Error(t, err, "WebSocket connection should fail without proper environment")
|
||||||
|
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
|
||||||
|
|
||||||
|
// Test with invalid URL
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Test with missing token
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
|
||||||
|
_, err2 := newWebSocketClient(agent)
|
||||||
|
assert.Error(t, err2, "WebSocket client creation should fail without token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
|
||||||
|
func TestConnectionManager_ReconnectionLogic(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
cm.eventChan = make(chan ConnectionEvent, 1)
|
||||||
|
|
||||||
|
// Test that isConnecting flag prevents duplicate reconnection attempts
|
||||||
|
// Start from connected state, then simulate disconnect
|
||||||
|
cm.State = WebSocketConnected
|
||||||
|
cm.isConnecting = false
|
||||||
|
|
||||||
|
// First disconnect should trigger reconnection logic
|
||||||
|
cm.handleStateChange(Disconnected)
|
||||||
|
assert.Equal(t, Disconnected, cm.State, "Should change to disconnected")
|
||||||
|
assert.True(t, cm.isConnecting, "Should set isConnecting flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_ConnectWithRateLimit tests connection rate limiting
|
||||||
|
func TestConnectionManager_ConnectWithRateLimit(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
|
||||||
|
// Set up environment for WebSocket client creation
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create WebSocket client
|
||||||
|
wsClient, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
cm.wsClient = wsClient
|
||||||
|
|
||||||
|
// Set recent connection attempt
|
||||||
|
cm.wsClient.lastConnectAttempt = time.Now()
|
||||||
|
|
||||||
|
// Test that connection is rate limited
|
||||||
|
err = cm.startWebSocketConnection()
|
||||||
|
assert.Error(t, err, "Should error due to rate limiting")
|
||||||
|
assert.Contains(t, err.Error(), "already connecting", "Error should indicate rate limiting")
|
||||||
|
|
||||||
|
// Test connection after rate limit expires
|
||||||
|
cm.wsClient.lastConnectAttempt = time.Now().Add(-10 * time.Second)
|
||||||
|
err = cm.startWebSocketConnection()
|
||||||
|
// This will fail due to no actual server, but should not be rate limited
|
||||||
|
assert.Error(t, err, "Connection should fail but not due to rate limiting")
|
||||||
|
assert.NotContains(t, err.Error(), "already connecting", "Error should not indicate rate limiting")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_StartWithInvalidConfig tests starting with invalid configuration
|
||||||
|
func TestConnectionManager_StartWithInvalidConfig(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
serverOptions := createTestServerOptions(t)
|
||||||
|
|
||||||
|
// Test starting when already started
|
||||||
|
cm.eventChan = make(chan ConnectionEvent, 5)
|
||||||
|
err := cm.Start(serverOptions)
|
||||||
|
assert.Error(t, err, "Should error when starting already started connection manager")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_CloseWebSocket tests WebSocket closing
|
||||||
|
func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
|
||||||
|
// Test closing when no WebSocket client exists
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
cm.closeWebSocket()
|
||||||
|
}, "Should not panic when closing nil WebSocket client")
|
||||||
|
|
||||||
|
// Set up environment and create WebSocket client
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
wsClient, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
cm.wsClient = wsClient
|
||||||
|
|
||||||
|
// Test closing when WebSocket client exists
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
cm.closeWebSocket()
|
||||||
|
}, "Should not panic when closing WebSocket client")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionManager_ConnectFlow tests the connect method
|
||||||
|
func TestConnectionManager_ConnectFlow(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
|
||||||
|
// Test connect without WebSocket client
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
cm.connect()
|
||||||
|
}, "Connect should not panic without WebSocket client")
|
||||||
|
}
|
||||||
117
beszel/internal/agent/data_dir.go
Normal file
117
beszel/internal/agent/data_dir.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getDataDir returns the path to the data directory for the agent and an error
|
||||||
|
// if the directory is not valid. Attempts to find the optimal data directory if
|
||||||
|
// no data directories are provided.
|
||||||
|
func getDataDir(dataDirs ...string) (string, error) {
|
||||||
|
if len(dataDirs) > 0 {
|
||||||
|
return testDataDirs(dataDirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir, _ := GetEnv("DATA_DIR")
|
||||||
|
if dataDir != "" {
|
||||||
|
dataDirs = append(dataDirs, dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
dataDirs = append(dataDirs,
|
||||||
|
filepath.Join(os.Getenv("APPDATA"), "beszel-agent"),
|
||||||
|
filepath.Join(os.Getenv("LOCALAPPDATA"), "beszel-agent"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dataDirs = append(dataDirs, "/var/lib/beszel-agent")
|
||||||
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||||
|
dataDirs = append(dataDirs, filepath.Join(homeDir, ".config", "beszel"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return testDataDirs(dataDirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDataDirs(paths []string) (string, error) {
|
||||||
|
// first check if the directory exists and is writable
|
||||||
|
for _, path := range paths {
|
||||||
|
if valid, _ := isValidDataDir(path, false); valid {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if the directory doesn't exist, try to create it
|
||||||
|
for _, path := range paths {
|
||||||
|
exists, _ := directoryExists(path)
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the created directory is actually writable
|
||||||
|
writable, _ := directoryIsWritable(path)
|
||||||
|
if !writable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("data directory not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidDataDir(path string, createIfNotExists bool) (bool, error) {
|
||||||
|
exists, err := directoryExists(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
if !createIfNotExists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err = os.MkdirAll(path, 0755); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always check if the directory is writable
|
||||||
|
writable, err := directoryIsWritable(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return writable, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// directoryExists checks if a directory exists
|
||||||
|
func directoryExists(path string) (bool, error) {
|
||||||
|
// Check if directory exists
|
||||||
|
stat, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !stat.IsDir() {
|
||||||
|
return false, fmt.Errorf("%s is not a directory", path)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// directoryIsWritable tests if a directory is writable by creating and removing a temporary file
|
||||||
|
func directoryIsWritable(path string) (bool, error) {
|
||||||
|
testFile := filepath.Join(path, ".write-test")
|
||||||
|
file, err := os.Create(testFile)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
defer os.Remove(testFile)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
263
beszel/internal/agent/data_dir_test.go
Normal file
263
beszel/internal/agent/data_dir_test.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDataDir(t *testing.T) {
|
||||||
|
// Test with explicit dataDir parameter
|
||||||
|
t.Run("explicit data dir", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
result, err := getDataDir(tempDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tempDir, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with explicit non-existent dataDir that can be created
|
||||||
|
t.Run("explicit data dir - create new", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
newDir := filepath.Join(tempDir, "new-data-dir")
|
||||||
|
result, err := getDataDir(newDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, newDir, result)
|
||||||
|
|
||||||
|
// Verify directory was created
|
||||||
|
stat, err := os.Stat(newDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, stat.IsDir())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with DATA_DIR environment variable
|
||||||
|
t.Run("DATA_DIR environment variable", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Set environment variable
|
||||||
|
oldValue := os.Getenv("DATA_DIR")
|
||||||
|
defer func() {
|
||||||
|
if oldValue == "" {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_DATA_DIR")
|
||||||
|
} else {
|
||||||
|
os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
||||||
|
|
||||||
|
result, err := getDataDir()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tempDir, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with invalid explicit dataDir
|
||||||
|
t.Run("invalid explicit data dir", func(t *testing.T) {
|
||||||
|
invalidPath := "/invalid/path/that/cannot/be/created"
|
||||||
|
_, err := getDataDir(invalidPath)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test fallback behavior (empty dataDir, no env var)
|
||||||
|
t.Run("fallback to default directories", func(t *testing.T) {
|
||||||
|
// Clear DATA_DIR environment variable
|
||||||
|
oldValue := os.Getenv("DATA_DIR")
|
||||||
|
defer func() {
|
||||||
|
if oldValue == "" {
|
||||||
|
os.Unsetenv("DATA_DIR")
|
||||||
|
} else {
|
||||||
|
os.Setenv("DATA_DIR", oldValue)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
os.Unsetenv("DATA_DIR")
|
||||||
|
|
||||||
|
// This will try platform-specific defaults, which may or may not work
|
||||||
|
// We're mainly testing that it doesn't panic and returns some result
|
||||||
|
result, err := getDataDir()
|
||||||
|
// We don't assert success/failure here since it depends on system permissions
|
||||||
|
// Just verify we get a string result if no error
|
||||||
|
if err == nil {
|
||||||
|
assert.NotEmpty(t, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestDataDirs(t *testing.T) {
|
||||||
|
// Test with existing valid directory
|
||||||
|
t.Run("existing valid directory", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
result, err := testDataDirs([]string{tempDir})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tempDir, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with multiple directories, first one valid
|
||||||
|
t.Run("multiple dirs - first valid", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
invalidDir := "/invalid/path"
|
||||||
|
result, err := testDataDirs([]string{tempDir, invalidDir})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tempDir, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with multiple directories, second one valid
|
||||||
|
t.Run("multiple dirs - second valid", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
invalidDir := "/invalid/path"
|
||||||
|
result, err := testDataDirs([]string{invalidDir, tempDir})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tempDir, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with non-existing directory that can be created
|
||||||
|
t.Run("create new directory", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
newDir := filepath.Join(tempDir, "new-dir")
|
||||||
|
result, err := testDataDirs([]string{newDir})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, newDir, result)
|
||||||
|
|
||||||
|
// Verify directory was created
|
||||||
|
stat, err := os.Stat(newDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, stat.IsDir())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with no valid directories
|
||||||
|
t.Run("no valid directories", func(t *testing.T) {
|
||||||
|
invalidPaths := []string{"/invalid/path1", "/invalid/path2"}
|
||||||
|
_, err := testDataDirs(invalidPaths)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "data directory not found")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidDataDir(t *testing.T) {
|
||||||
|
// Test with existing directory
|
||||||
|
t.Run("existing directory", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
valid, err := isValidDataDir(tempDir, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, valid)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with non-existing directory, createIfNotExists=false
|
||||||
|
t.Run("non-existing dir - no create", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
|
||||||
|
valid, err := isValidDataDir(nonExistentDir, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, valid)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with non-existing directory, createIfNotExists=true
|
||||||
|
t.Run("non-existing dir - create", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
newDir := filepath.Join(tempDir, "new-dir")
|
||||||
|
valid, err := isValidDataDir(newDir, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, valid)
|
||||||
|
|
||||||
|
// Verify directory was created
|
||||||
|
stat, err := os.Stat(newDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, stat.IsDir())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with file instead of directory
|
||||||
|
t.Run("file instead of directory", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
tempFile := filepath.Join(tempDir, "testfile")
|
||||||
|
err := os.WriteFile(tempFile, []byte("test"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
valid, err := isValidDataDir(tempFile, false)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.False(t, valid)
|
||||||
|
assert.Contains(t, err.Error(), "is not a directory")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectoryExists(t *testing.T) {
|
||||||
|
// Test with existing directory
|
||||||
|
t.Run("existing directory", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
exists, err := directoryExists(tempDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with non-existing directory
|
||||||
|
t.Run("non-existing directory", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
|
||||||
|
exists, err := directoryExists(nonExistentDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with file instead of directory
|
||||||
|
t.Run("file instead of directory", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
tempFile := filepath.Join(tempDir, "testfile")
|
||||||
|
err := os.WriteFile(tempFile, []byte("test"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
exists, err := directoryExists(tempFile)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.False(t, exists)
|
||||||
|
assert.Contains(t, err.Error(), "is not a directory")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectoryIsWritable(t *testing.T) {
|
||||||
|
// Test with writable directory
|
||||||
|
t.Run("writable directory", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
writable, err := directoryIsWritable(tempDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, writable)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with non-existing directory
|
||||||
|
t.Run("non-existing directory", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
|
||||||
|
writable, err := directoryIsWritable(nonExistentDir)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.False(t, writable)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with non-writable directory (Unix-like systems only)
|
||||||
|
t.Run("non-writable directory", func(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||||
|
t.Skip("Skipping non-writable directory test on", runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
readOnlyDir := filepath.Join(tempDir, "readonly")
|
||||||
|
|
||||||
|
// Create the directory
|
||||||
|
err := os.Mkdir(readOnlyDir, 0755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Make it read-only
|
||||||
|
err = os.Chmod(readOnlyDir, 0444)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Restore permissions after test for cleanup
|
||||||
|
defer func() {
|
||||||
|
os.Chmod(readOnlyDir, 0755)
|
||||||
|
}()
|
||||||
|
|
||||||
|
writable, err := directoryIsWritable(readOnlyDir)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.False(t, writable)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/container"
|
"beszel/internal/entities/container"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -27,6 +28,9 @@ type dockerManager struct {
|
|||||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
|
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||||
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
}
|
}
|
||||||
|
|
||||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||||
@@ -63,10 +67,9 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
dm.apiContainerList = dm.apiContainerList[:0]
|
dm.apiContainerList = dm.apiContainerList[:0]
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
|
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +86,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
|
|
||||||
var failedContainers []*container.ApiInfo
|
var failedContainers []*container.ApiInfo
|
||||||
|
|
||||||
for _, ctr := range dm.apiContainerList {
|
for i := range dm.apiContainerList {
|
||||||
|
ctr := dm.apiContainerList[i]
|
||||||
ctr.IdShort = ctr.Id[:12]
|
ctr.IdShort = ctr.Id[:12]
|
||||||
dm.validIds[ctr.IdShort] = struct{}{}
|
dm.validIds[ctr.IdShort] = struct{}{}
|
||||||
// check if container is less than 1 minute old (possible restart)
|
// check if container is less than 1 minute old (possible restart)
|
||||||
@@ -111,7 +115,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
// retry failed containers separately so we can run them in parallel (docker 24 bug)
|
// retry failed containers separately so we can run them in parallel (docker 24 bug)
|
||||||
if len(failedContainers) > 0 {
|
if len(failedContainers) > 0 {
|
||||||
slog.Debug("Retrying failed containers", "count", len(failedContainers))
|
slog.Debug("Retrying failed containers", "count", len(failedContainers))
|
||||||
for _, ctr := range failedContainers {
|
for i := range failedContainers {
|
||||||
|
ctr := failedContainers[i]
|
||||||
dm.queue()
|
dm.queue()
|
||||||
go func() {
|
go func() {
|
||||||
defer dm.dequeue()
|
defer dm.dequeue()
|
||||||
@@ -164,8 +169,13 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
|
|||||||
stats.NetworkRecv = 0
|
stats.NetworkRecv = 0
|
||||||
|
|
||||||
// docker host container stats response
|
// docker host container stats response
|
||||||
var res container.ApiStats
|
// res := dm.getApiStats()
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
// defer dm.putApiStats(res)
|
||||||
|
//
|
||||||
|
|
||||||
|
res := dm.apiStats
|
||||||
|
res.Networks = nil
|
||||||
|
if err := dm.decode(resp, res); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,9 +183,14 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
|
|||||||
var usedMemory uint64
|
var usedMemory uint64
|
||||||
var cpuPct float64
|
var cpuPct float64
|
||||||
|
|
||||||
|
// store current cpu stats
|
||||||
|
prevCpuContainer, prevCpuSystem := stats.CpuContainer, stats.CpuSystem
|
||||||
|
stats.CpuContainer = res.CPUStats.CPUUsage.TotalUsage
|
||||||
|
stats.CpuSystem = res.CPUStats.SystemUsage
|
||||||
|
|
||||||
if dm.isWindows {
|
if dm.isWindows {
|
||||||
usedMemory = res.MemoryStats.PrivateWorkingSet
|
usedMemory = res.MemoryStats.PrivateWorkingSet
|
||||||
cpuPct = res.CalculateCpuPercentWindows(stats.PrevCpu[0], stats.PrevRead)
|
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime)
|
||||||
} else {
|
} else {
|
||||||
// check if container has valid data, otherwise may be in restart loop (#103)
|
// check if container has valid data, otherwise may be in restart loop (#103)
|
||||||
if res.MemoryStats.Usage == 0 {
|
if res.MemoryStats.Usage == 0 {
|
||||||
@@ -187,13 +202,12 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
|
|||||||
}
|
}
|
||||||
usedMemory = res.MemoryStats.Usage - memCache
|
usedMemory = res.MemoryStats.Usage - memCache
|
||||||
|
|
||||||
cpuPct = res.CalculateCpuPercentLinux(stats.PrevCpu)
|
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cpuPct > 100 {
|
if cpuPct > 100 {
|
||||||
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
||||||
}
|
}
|
||||||
stats.PrevCpu = [2]uint64{res.CPUStats.CPUUsage.TotalUsage, res.CPUStats.SystemUsage}
|
|
||||||
|
|
||||||
// network
|
// network
|
||||||
var total_sent, total_recv uint64
|
var total_sent, total_recv uint64
|
||||||
@@ -201,21 +215,25 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
|
|||||||
total_sent += v.TxBytes
|
total_sent += v.TxBytes
|
||||||
total_recv += v.RxBytes
|
total_recv += v.RxBytes
|
||||||
}
|
}
|
||||||
var sent_delta, recv_delta float64
|
var sent_delta, recv_delta uint64
|
||||||
// prevent first run from sending all prev sent/recv bytes
|
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
|
||||||
if initialized {
|
if initialized && millisecondsElapsed > 0 {
|
||||||
secondsElapsed := time.Since(stats.PrevRead).Seconds()
|
// get bytes per second
|
||||||
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
|
sent_delta = (total_sent - stats.PrevNet.Sent) * 1000 / millisecondsElapsed
|
||||||
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
|
recv_delta = (total_recv - stats.PrevNet.Recv) * 1000 / millisecondsElapsed
|
||||||
|
// check for unrealistic network values (> 5GB/s)
|
||||||
|
if sent_delta > 5e9 || recv_delta > 5e9 {
|
||||||
|
slog.Warn("Bad network delta", "container", name)
|
||||||
|
sent_delta, recv_delta = 0, 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stats.PrevNet.Sent = total_sent
|
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
|
||||||
stats.PrevNet.Recv = total_recv
|
|
||||||
|
|
||||||
stats.Cpu = twoDecimals(cpuPct)
|
stats.Cpu = twoDecimals(cpuPct)
|
||||||
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||||
stats.NetworkSent = bytesToMegabytes(sent_delta)
|
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
||||||
stats.NetworkRecv = bytesToMegabytes(recv_delta)
|
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
||||||
stats.PrevRead = res.Read
|
stats.PrevReadTime = res.Read
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -231,7 +249,6 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
func newDockerManager(a *Agent) *dockerManager {
|
func newDockerManager(a *Agent) *dockerManager {
|
||||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
slog.Info("DOCKER_HOST", "host", dockerHost)
|
|
||||||
// return nil if set to empty string
|
// return nil if set to empty string
|
||||||
if dockerHost == "" {
|
if dockerHost == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -242,7 +259,6 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
|
|
||||||
parsedURL, err := url.Parse(dockerHost)
|
parsedURL, err := url.Parse(dockerHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error parsing DOCKER_HOST", "err", err)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +306,7 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
|
apiStats: &container.ApiStats{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using podman, return client
|
// If using podman, return client
|
||||||
@@ -308,9 +325,8 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
|
if err := manager.decode(resp, &versionInfo); err != nil {
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +340,22 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
||||||
|
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
||||||
|
if dm.buf == nil {
|
||||||
|
// initialize buffer with 256kb starting size
|
||||||
|
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
|
||||||
|
dm.decoder = json.NewDecoder(dm.buf)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
defer dm.buf.Reset()
|
||||||
|
_, err := dm.buf.ReadFrom(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return dm.decoder.Decode(d)
|
||||||
|
}
|
||||||
|
|
||||||
// Test docker / podman sockets and return if one exists
|
// Test docker / podman sockets and return if one exists
|
||||||
func getDockerHost() string {
|
func getDockerHost() string {
|
||||||
scheme := "unix://"
|
scheme := "unix://"
|
||||||
|
|||||||
@@ -18,24 +18,24 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Commands
|
// Commands
|
||||||
nvidiaSmiCmd = "nvidia-smi"
|
nvidiaSmiCmd string = "nvidia-smi"
|
||||||
rocmSmiCmd = "rocm-smi"
|
rocmSmiCmd string = "rocm-smi"
|
||||||
tegraStatsCmd = "tegrastats"
|
tegraStatsCmd string = "tegrastats"
|
||||||
|
|
||||||
// Polling intervals
|
// Polling intervals
|
||||||
nvidiaSmiInterval = "4" // in seconds
|
nvidiaSmiInterval string = "4" // in seconds
|
||||||
tegraStatsInterval = "3700" // in milliseconds
|
tegraStatsInterval string = "3700" // in milliseconds
|
||||||
rocmSmiInterval = 4300 * time.Millisecond
|
rocmSmiInterval time.Duration = 4300 * time.Millisecond
|
||||||
|
|
||||||
// Command retry and timeout constants
|
// Command retry and timeout constants
|
||||||
retryWaitTime = 5 * time.Second
|
retryWaitTime time.Duration = 5 * time.Second
|
||||||
maxFailureRetries = 5
|
maxFailureRetries int = 5
|
||||||
|
|
||||||
cmdBufferSize = 10 * 1024
|
cmdBufferSize uint16 = 10 * 1024
|
||||||
|
|
||||||
// Unit Conversions
|
// Unit Conversions
|
||||||
mebibytesInAMegabyte = 1.024 // nvidia-smi reports memory in MiB
|
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
|
||||||
milliwattsInAWatt = 1000.0 // tegrastats reports power in mW
|
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
|
||||||
)
|
)
|
||||||
|
|
||||||
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
||||||
@@ -243,21 +243,26 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
|||||||
// copy / reset the data
|
// copy / reset the data
|
||||||
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
||||||
for id, gpu := range gm.GpuDataMap {
|
for id, gpu := range gm.GpuDataMap {
|
||||||
// sum the data
|
gpuAvg := *gpu
|
||||||
gpu.Temperature = twoDecimals(gpu.Temperature)
|
|
||||||
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
||||||
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
gpu.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
gpu.Power = twoDecimals(gpu.Power / gpu.Count)
|
|
||||||
// reset the count
|
// avoid division by zero
|
||||||
gpu.Count = 1
|
if gpu.Count > 0 {
|
||||||
// dereference to avoid overwriting anything else
|
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
||||||
gpuCopy := *gpu
|
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset accumulators in the original
|
||||||
|
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
|
||||||
|
|
||||||
// append id to the name if there are multiple GPUs with the same name
|
// append id to the name if there are multiple GPUs with the same name
|
||||||
if nameCounts[gpu.Name] > 1 {
|
if nameCounts[gpu.Name] > 1 {
|
||||||
gpuCopy.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
||||||
}
|
}
|
||||||
gpuData[id] = gpuCopy
|
gpuData[id] = gpuAvg
|
||||||
}
|
}
|
||||||
slog.Debug("GPU", "data", gpuData)
|
slog.Debug("GPU", "data", gpuData)
|
||||||
return gpuData
|
return gpuData
|
||||||
|
|||||||
@@ -279,6 +279,19 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
Count: 1,
|
Count: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "orin nano",
|
||||||
|
input: "06-18-2025 11:25:24 RAM 3452/7620MB (lfb 25x4MB) SWAP 1518/16384MB (cached 174MB) CPU [1%@1420,2%@1420,0%@1420,2%@1420,2%@729,1%@729] GR3D_FREQ 0% cpu@50.031C soc2@49.031C soc0@50C gpu@49.031C tj@50.25C soc1@50.25C VDD_IN 4824mW/4824mW VDD_CPU_GPU_CV 518mW/518mW VDD_SOC 1475mW/1475mW",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "GPU",
|
||||||
|
MemoryUsed: 3452.0,
|
||||||
|
MemoryTotal: 7620.0,
|
||||||
|
Usage: 0.0,
|
||||||
|
Temperature: 50.25,
|
||||||
|
Power: 0.518,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "missing temperature",
|
name: "missing temperature",
|
||||||
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
||||||
@@ -318,44 +331,85 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCurrentData(t *testing.T) {
|
func TestGetCurrentData(t *testing.T) {
|
||||||
gm := &GPUManager{
|
t.Run("calculates averages and resets accumulators", func(t *testing.T) {
|
||||||
GpuDataMap: map[string]*system.GPUData{
|
gm := &GPUManager{
|
||||||
"0": {
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
Name: "GPU1",
|
"0": {
|
||||||
Temperature: 50,
|
Name: "GPU1",
|
||||||
MemoryUsed: 2048,
|
Temperature: 50,
|
||||||
MemoryTotal: 4096,
|
MemoryUsed: 2048,
|
||||||
Usage: 100, // 100 over 2 counts = 50 avg
|
MemoryTotal: 4096,
|
||||||
Power: 200, // 200 over 2 counts = 100 avg
|
Usage: 100, // 100 over 2 counts = 50 avg
|
||||||
Count: 2,
|
Power: 200, // 200 over 2 counts = 100 avg
|
||||||
|
Count: 2,
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
Name: "GPU1",
|
||||||
|
Temperature: 60,
|
||||||
|
MemoryUsed: 3072,
|
||||||
|
MemoryTotal: 8192,
|
||||||
|
Usage: 30,
|
||||||
|
Power: 60,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
Name: "GPU 2",
|
||||||
|
Temperature: 70,
|
||||||
|
MemoryUsed: 4096,
|
||||||
|
MemoryTotal: 8192,
|
||||||
|
Usage: 200,
|
||||||
|
Power: 400,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"1": {
|
}
|
||||||
Name: "GPU1",
|
|
||||||
Temperature: 60,
|
result := gm.GetCurrentData()
|
||||||
MemoryUsed: 3072,
|
|
||||||
MemoryTotal: 8192,
|
// Verify name disambiguation
|
||||||
Usage: 30,
|
assert.Equal(t, "GPU1 0", result["0"].Name)
|
||||||
Power: 60,
|
assert.Equal(t, "GPU1 1", result["1"].Name)
|
||||||
Count: 1,
|
assert.Equal(t, "GPU 2", result["2"].Name)
|
||||||
|
|
||||||
|
// Check averaged values in the result
|
||||||
|
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
|
||||||
|
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
|
||||||
|
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
|
||||||
|
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
||||||
|
|
||||||
|
// Verify that accumulators in the original map are reset
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles zero count without panicking", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "TestGPU",
|
||||||
|
Count: 0,
|
||||||
|
Usage: 0,
|
||||||
|
Power: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
|
|
||||||
result := gm.GetCurrentData()
|
var result map[string]system.GPUData
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
result = gm.GetCurrentData()
|
||||||
|
})
|
||||||
|
|
||||||
// Verify name disambiguation
|
// Check that usage and power are 0
|
||||||
assert.Equal(t, "GPU1 0", result["0"].Name)
|
assert.Equal(t, 0.0, result["0"].Usage)
|
||||||
assert.Equal(t, "GPU1 1", result["1"].Name)
|
assert.Equal(t, 0.0, result["0"].Power)
|
||||||
|
|
||||||
// Check averaged values
|
// Verify reset count
|
||||||
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count)
|
||||||
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
|
})
|
||||||
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
|
|
||||||
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
|
||||||
|
|
||||||
// Verify reset counts
|
|
||||||
assert.Equal(t, float64(1), gm.GpuDataMap["0"].Count)
|
|
||||||
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectGPUs(t *testing.T) {
|
func TestDetectGPUs(t *testing.T) {
|
||||||
@@ -722,6 +776,18 @@ func TestAccumulation(t *testing.T) {
|
|||||||
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
|
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
|
||||||
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
|
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify that accumulators in the original map are reset
|
||||||
|
for id := range tt.expectedValues {
|
||||||
|
gpu, exists := gm.GpuDataMap[id]
|
||||||
|
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, float64(0), gpu.Count, "Count should be reset for GPU ID %s", id)
|
||||||
|
assert.Equal(t, float64(0), gpu.Usage, "Usage should be reset for GPU ID %s", id)
|
||||||
|
assert.Equal(t, float64(0), gpu.Power, "Power should be reset for GPU ID %s", id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Health checks if the agent's server is running by attempting to connect to it.
|
|
||||||
//
|
|
||||||
// If an error occurs when attempting to connect to the server, it returns the error.
|
|
||||||
func Health(addr string, network string) error {
|
|
||||||
conn, err := net.DialTimeout(network, addr, 4*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
conn.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
43
beszel/internal/agent/health/health.go
Normal file
43
beszel/internal/agent/health/health.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Package health provides functions to check and update the health of the agent.
|
||||||
|
// It uses a file in the temp directory to store the timestamp of the last connection attempt.
|
||||||
|
// If the timestamp is older than 90 seconds, the agent is considered unhealthy.
|
||||||
|
// NB: The agent must be started with the Start() method to be considered healthy.
|
||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// healthFile is the path to the health file
|
||||||
|
var healthFile = filepath.Join(os.TempDir(), "beszel_health")
|
||||||
|
|
||||||
|
// Check checks if the agent is connected by checking the modification time of the health file
|
||||||
|
func Check() error {
|
||||||
|
fileInfo, err := os.Stat(healthFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if time.Since(fileInfo.ModTime()) > 91*time.Second {
|
||||||
|
log.Println("over 90 seconds since last connection")
|
||||||
|
return errors.New("unhealthy")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates the modification time of the health file
|
||||||
|
func Update() error {
|
||||||
|
file, err := os.Create(healthFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanUp removes the health file
|
||||||
|
func CleanUp() error {
|
||||||
|
return os.Remove(healthFile)
|
||||||
|
}
|
||||||
67
beszel/internal/agent/health/health_test.go
Normal file
67
beszel/internal/agent/health/health_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"testing/synctest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealth(t *testing.T) {
|
||||||
|
// Override healthFile to use a temporary directory for this test.
|
||||||
|
originalHealthFile := healthFile
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
healthFile = filepath.Join(tmpDir, "beszel_health_test")
|
||||||
|
defer func() { healthFile = originalHealthFile }()
|
||||||
|
|
||||||
|
t.Run("check with no health file", func(t *testing.T) {
|
||||||
|
err := Check()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, os.IsNotExist(err), "expected a file-not-exist error, but got: %v", err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update and check", func(t *testing.T) {
|
||||||
|
err := Update()
|
||||||
|
require.NoError(t, err, "Update() failed")
|
||||||
|
|
||||||
|
err = Check()
|
||||||
|
assert.NoError(t, err, "Check() failed immediately after Update()")
|
||||||
|
})
|
||||||
|
|
||||||
|
// This test uses synctest to simulate time passing.
|
||||||
|
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
||||||
|
t.Run("check with simulated time", func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// Update the file to set the initial timestamp.
|
||||||
|
require.NoError(t, Update(), "Update() failed inside synctest")
|
||||||
|
|
||||||
|
// Set the mtime to the current fake time to align the file's timestamp with the simulated clock.
|
||||||
|
now := time.Now()
|
||||||
|
require.NoError(t, os.Chtimes(healthFile, now, now), "Chtimes failed")
|
||||||
|
|
||||||
|
// Wait a duration less than the threshold.
|
||||||
|
time.Sleep(89 * time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
// The check should still pass.
|
||||||
|
assert.NoError(t, Check(), "Check() failed after 89s")
|
||||||
|
|
||||||
|
// Wait for the total duration to exceed the threshold.
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
// The check should now fail as unhealthy.
|
||||||
|
err := Check()
|
||||||
|
require.Error(t, err, "Check() should have failed after 91s")
|
||||||
|
assert.Equal(t, "unhealthy", err.Error(), "Check() returned wrong error")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"beszel/internal/agent"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupTestServer creates a temporary server for testing
|
|
||||||
func setupTestServer(t *testing.T) (string, func()) {
|
|
||||||
// Create a temporary socket file for Unix socket testing
|
|
||||||
tempSockFile := os.TempDir() + "/beszel_health_test.sock"
|
|
||||||
|
|
||||||
// Clean up any existing socket file
|
|
||||||
os.Remove(tempSockFile)
|
|
||||||
|
|
||||||
// Create a listener
|
|
||||||
listener, err := net.Listen("unix", tempSockFile)
|
|
||||||
require.NoError(t, err, "Failed to create test listener")
|
|
||||||
|
|
||||||
// Start a simple server in a goroutine
|
|
||||||
go func() {
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return // Listener closed
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
// Just accept the connection and do nothing
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Return the socket file path and a cleanup function
|
|
||||||
return tempSockFile, func() {
|
|
||||||
listener.Close()
|
|
||||||
os.Remove(tempSockFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupTCPTestServer creates a temporary TCP server for testing
|
|
||||||
func setupTCPTestServer(t *testing.T) (string, func()) {
|
|
||||||
// Listen on a random available port
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err, "Failed to create test listener")
|
|
||||||
|
|
||||||
// Get the port that was assigned
|
|
||||||
addr := listener.Addr().(*net.TCPAddr)
|
|
||||||
port := addr.Port
|
|
||||||
|
|
||||||
// Start a simple server in a goroutine
|
|
||||||
go func() {
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return // Listener closed
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
// Just accept the connection and do nothing
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Return the address and a cleanup function
|
|
||||||
return fmt.Sprintf("127.0.0.1:%d", port), func() {
|
|
||||||
listener.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHealth(t *testing.T) {
|
|
||||||
t.Run("server is running (unix socket)", func(t *testing.T) {
|
|
||||||
// Setup a test server
|
|
||||||
sockFile, cleanup := setupTestServer(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// Run the health check with explicit parameters
|
|
||||||
err := agent.Health(sockFile, "unix")
|
|
||||||
require.NoError(t, err, "Failed to check health")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("server is running (tcp address)", func(t *testing.T) {
|
|
||||||
// Setup a test server
|
|
||||||
addr, cleanup := setupTCPTestServer(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// Run the health check with explicit parameters
|
|
||||||
err := agent.Health(addr, "tcp")
|
|
||||||
require.NoError(t, err, "Failed to check health")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("server is not running", func(t *testing.T) {
|
|
||||||
// Use an address that's likely not in use
|
|
||||||
addr := "127.0.0.1:65535"
|
|
||||||
|
|
||||||
// Run the health check with explicit parameters
|
|
||||||
err := agent.Health(addr, "tcp")
|
|
||||||
require.Error(t, err, "Health check should return an error when server is not running")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid network", func(t *testing.T) {
|
|
||||||
// Use an invalid network type
|
|
||||||
err := agent.Health("127.0.0.1:8080", "invalid_network")
|
|
||||||
require.Error(t, err, "Health check should return an error with invalid network")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("unix socket not found", func(t *testing.T) {
|
|
||||||
// Use a non-existent unix socket
|
|
||||||
nonExistentSocket := os.TempDir() + "/non_existent_socket.sock"
|
|
||||||
|
|
||||||
// Make sure it really doesn't exist
|
|
||||||
os.Remove(nonExistentSocket)
|
|
||||||
|
|
||||||
err := agent.Health(nonExistentSocket, "unix")
|
|
||||||
require.Error(t, err, "Health check should return an error when socket doesn't exist")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
80
beszel/internal/agent/lhm/beszel_lhm.cs
Normal file
80
beszel/internal/agent/lhm/beszel_lhm.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using LibreHardwareMonitor.Hardware;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
static void Main()
|
||||||
|
{
|
||||||
|
var computer = new Computer
|
||||||
|
{
|
||||||
|
IsCpuEnabled = true,
|
||||||
|
IsGpuEnabled = true,
|
||||||
|
IsMemoryEnabled = true,
|
||||||
|
IsMotherboardEnabled = true,
|
||||||
|
IsStorageEnabled = true,
|
||||||
|
// IsPsuEnabled = true,
|
||||||
|
// IsNetworkEnabled = true,
|
||||||
|
};
|
||||||
|
computer.Open();
|
||||||
|
|
||||||
|
var reader = Console.In;
|
||||||
|
var writer = Console.Out;
|
||||||
|
|
||||||
|
string line;
|
||||||
|
while ((line = reader.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
if (line.Trim().Equals("getTemps", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var hw in computer.Hardware)
|
||||||
|
{
|
||||||
|
// process main hardware sensors
|
||||||
|
ProcessSensors(hw, writer);
|
||||||
|
|
||||||
|
// process subhardware sensors
|
||||||
|
foreach (var subhardware in hw.SubHardware)
|
||||||
|
{
|
||||||
|
ProcessSensors(subhardware, writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// send empty line to signal end of sensor data
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
computer.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ProcessSensors(IHardware hardware, System.IO.TextWriter writer)
|
||||||
|
{
|
||||||
|
var updated = false;
|
||||||
|
foreach (var sensor in hardware.Sensors)
|
||||||
|
{
|
||||||
|
var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
|
||||||
|
if (!validTemp || sensor.Name.Contains("Distance"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated)
|
||||||
|
{
|
||||||
|
hardware.Update();
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = sensor.Name;
|
||||||
|
// if sensor.Name starts with "Temperature" replace with hardware.Identifier but retain the rest of the name.
|
||||||
|
// usually this is a number like Temperature 3
|
||||||
|
if (sensor.Name.StartsWith("Temperature"))
|
||||||
|
{
|
||||||
|
name = hardware.Identifier.ToString().Replace("/", "_").TrimStart('_') + sensor.Name.Substring(11);
|
||||||
|
}
|
||||||
|
|
||||||
|
// invariant culture assures the value is parsable as a float
|
||||||
|
var value = sensor.Value.Value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||||
|
// write the name and value to the writer
|
||||||
|
writer.WriteLine($"{name}|{value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
beszel/internal/agent/lhm/beszel_lhm.csproj
Normal file
11
beszel/internal/agent/lhm/beszel_lhm.csproj
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<Platforms>x64</Platforms>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -57,6 +57,7 @@ func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
|||||||
strings.HasPrefix(v.Name, "docker"),
|
strings.HasPrefix(v.Name, "docker"),
|
||||||
strings.HasPrefix(v.Name, "br-"),
|
strings.HasPrefix(v.Name, "br-"),
|
||||||
strings.HasPrefix(v.Name, "veth"),
|
strings.HasPrefix(v.Name, "veth"),
|
||||||
|
strings.HasPrefix(v.Name, "bond"),
|
||||||
v.BytesRecv == 0,
|
v.BytesRecv == 0,
|
||||||
v.BytesSent == 0:
|
v.BytesSent == 0:
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
@@ -30,6 +33,9 @@ func (a *Agent) newSensorConfig() *SensorConfig {
|
|||||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
|
||||||
|
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
|
||||||
|
|
||||||
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||||
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
||||||
@@ -78,8 +84,18 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
// reset high temp
|
// reset high temp
|
||||||
a.systemInfo.DashboardTemp = 0
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
// get sensor data
|
temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
temps, _ := sensors.TemperaturesWithContext(a.sensorConfig.context)
|
if err != nil {
|
||||||
|
// retry once on panic (gopsutil/issues/1832)
|
||||||
|
temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Error updating temperatures", "err", err)
|
||||||
|
if len(systemStats.Temperatures) > 0 {
|
||||||
|
systemStats.Temperatures = make(map[string]float64)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
slog.Debug("Temperature", "sensors", temps)
|
slog.Debug("Temperature", "sensors", temps)
|
||||||
|
|
||||||
// return if no sensors
|
// return if no sensors
|
||||||
@@ -89,6 +105,15 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
|
|
||||||
systemStats.Temperatures = make(map[string]float64, len(temps))
|
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||||
for i, sensor := range temps {
|
for i, sensor := range temps {
|
||||||
|
// check for malformed strings on darwin (gopsutil/issues/1832)
|
||||||
|
if runtime.GOOS == "darwin" && !utf8.ValidString(sensor.SensorKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// scale temperature
|
||||||
|
if sensor.Temperature != 0 && sensor.Temperature < 1 {
|
||||||
|
sensor.Temperature = scaleTemperature(sensor.Temperature)
|
||||||
|
}
|
||||||
// skip if temperature is unreasonable
|
// skip if temperature is unreasonable
|
||||||
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
||||||
continue
|
continue
|
||||||
@@ -103,15 +128,28 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// set dashboard temperature
|
// set dashboard temperature
|
||||||
if a.sensorConfig.primarySensor == "" {
|
switch a.sensorConfig.primarySensor {
|
||||||
|
case "":
|
||||||
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
|
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
|
||||||
} else if a.sensorConfig.primarySensor == sensorName {
|
case sensorName:
|
||||||
a.systemInfo.DashboardTemp = sensor.Temperature
|
a.systemInfo.DashboardTemp = sensor.Temperature
|
||||||
}
|
}
|
||||||
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getTempsWithPanicRecovery wraps sensors.TemperaturesWithContext to recover from panics (gopsutil/issues/1832)
|
||||||
|
func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.TemperatureStat, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// get sensor data (error ignored intentionally as it may be only with one sensor)
|
||||||
|
temps, _ = getTemps(a.sensorConfig.context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
||||||
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
||||||
// if no sensors configured, everything is valid
|
// if no sensors configured, everything is valid
|
||||||
@@ -141,3 +179,19 @@ func isValidSensor(sensorName string, config *SensorConfig) bool {
|
|||||||
|
|
||||||
return config.isBlacklist
|
return config.isBlacklist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scaleTemperature scales temperatures in fractional values to reasonable Celsius values
|
||||||
|
func scaleTemperature(temp float64) float64 {
|
||||||
|
if temp > 1 {
|
||||||
|
return temp
|
||||||
|
}
|
||||||
|
scaled100 := temp * 100
|
||||||
|
scaled1000 := temp * 1000
|
||||||
|
|
||||||
|
if scaled100 >= 15 && scaled100 <= 95 {
|
||||||
|
return scaled100
|
||||||
|
} else if scaled1000 >= 15 && scaled1000 <= 95 {
|
||||||
|
return scaled1000
|
||||||
|
}
|
||||||
|
return scaled100
|
||||||
|
}
|
||||||
|
|||||||
9
beszel/internal/agent/sensors_default.go
Normal file
9
beszel/internal/agent/sensors_default.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var getSensorTemps = sensors.TemperaturesWithContext
|
||||||
@@ -4,11 +4,14 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -372,3 +375,179 @@ func TestNewSensorConfig(t *testing.T) {
|
|||||||
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
|
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
|
||||||
assert.Equal(t, "/test/path", sysPath)
|
assert.Equal(t, "/test/path", sysPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScaleTemperature(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input float64
|
||||||
|
expected float64
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
// Normal temperatures (no scaling needed)
|
||||||
|
{"normal_cpu_temp", 45.0, 45.0, "Normal CPU temperature"},
|
||||||
|
{"normal_room_temp", 25.0, 25.0, "Normal room temperature"},
|
||||||
|
{"high_cpu_temp", 85.0, 85.0, "High CPU temperature"},
|
||||||
|
// Zero temperature
|
||||||
|
{"zero_temp", 0.0, 0.0, "Zero temperature"},
|
||||||
|
// Fractional values that should use 100x scaling
|
||||||
|
{"fractional_45c", 0.45, 45.0, "0.45 should become 45°C (100x)"},
|
||||||
|
{"fractional_25c", 0.25, 25.0, "0.25 should become 25°C (100x)"},
|
||||||
|
{"fractional_60c", 0.60, 60.0, "0.60 should become 60°C (100x)"},
|
||||||
|
{"fractional_75c", 0.75, 75.0, "0.75 should become 75°C (100x)"},
|
||||||
|
{"fractional_30c", 0.30, 30.0, "0.30 should become 30°C (100x)"},
|
||||||
|
// Fractional values that should use 1000x scaling
|
||||||
|
{"millifractional_45c", 0.045, 45.0, "0.045 should become 45°C (1000x)"},
|
||||||
|
{"millifractional_25c", 0.025, 25.0, "0.025 should become 25°C (1000x)"},
|
||||||
|
{"millifractional_60c", 0.060, 60.0, "0.060 should become 60°C (1000x)"},
|
||||||
|
{"millifractional_75c", 0.075, 75.0, "0.075 should become 75°C (1000x)"},
|
||||||
|
{"millifractional_35c", 0.035, 35.0, "0.035 should become 35°C (1000x)"},
|
||||||
|
// Edge cases - values outside reasonable range
|
||||||
|
{"very_low_fractional", 0.01, 1.0, "0.01 should default to 100x scaling (1°C)"},
|
||||||
|
{"very_high_fractional", 0.99, 99.0, "0.99 should default to 100x scaling (99°C)"},
|
||||||
|
{"extremely_low", 0.001, 0.1, "0.001 should default to 100x scaling (0.1°C)"},
|
||||||
|
// Boundary cases around the reasonable range (15-95°C)
|
||||||
|
{"boundary_low_100x", 0.15, 15.0, "0.15 should use 100x scaling (15°C)"},
|
||||||
|
{"boundary_high_100x", 0.95, 95.0, "0.95 should use 100x scaling (95°C)"},
|
||||||
|
{"boundary_low_1000x", 0.015, 15.0, "0.015 should use 1000x scaling (15°C)"},
|
||||||
|
{"boundary_high_1000x", 0.095, 95.0, "0.095 should use 1000x scaling (95°C)"},
|
||||||
|
// Values just outside reasonable range
|
||||||
|
{"just_below_range_100x", 0.14, 14.0, "0.14 should default to 100x (14°C)"},
|
||||||
|
{"just_above_range_100x", 0.96, 96.0, "0.96 should default to 100x (96°C)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := scaleTemperature(tt.input)
|
||||||
|
assert.InDelta(t, tt.expected, result, 0.001,
|
||||||
|
"scaleTemperature(%v) = %v, expected %v (%s)",
|
||||||
|
tt.input, result, tt.expected, tt.desc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScaleTemperatureLogic(t *testing.T) {
|
||||||
|
// Test the logic flow for ambiguous cases
|
||||||
|
t.Run("prefers_100x_when_both_valid", func(t *testing.T) {
|
||||||
|
// 0.5 could be 50°C (100x) or 500°C (1000x)
|
||||||
|
// Should prefer 100x since it's tried first and is in range
|
||||||
|
result := scaleTemperature(0.5)
|
||||||
|
expected := 50.0
|
||||||
|
assert.InDelta(t, expected, result, 0.001,
|
||||||
|
"scaleTemperature(0.5) = %v, expected %v (should prefer 100x scaling)",
|
||||||
|
result, expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses_1000x_when_100x_too_low", func(t *testing.T) {
|
||||||
|
// 0.05 -> 5°C (100x, too low) or 50°C (1000x, in range)
|
||||||
|
// Should use 1000x since 100x is below reasonable range
|
||||||
|
result := scaleTemperature(0.05)
|
||||||
|
expected := 50.0
|
||||||
|
assert.InDelta(t, expected, result, 0.001,
|
||||||
|
"scaleTemperature(0.05) = %v, expected %v (should use 1000x scaling)",
|
||||||
|
result, expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("defaults_to_100x_when_both_invalid", func(t *testing.T) {
|
||||||
|
// 0.005 -> 0.5°C (100x, too low) or 5°C (1000x, too low)
|
||||||
|
// Should default to 100x scaling
|
||||||
|
result := scaleTemperature(0.005)
|
||||||
|
expected := 0.5
|
||||||
|
assert.InDelta(t, expected, result, 0.001,
|
||||||
|
"scaleTemperature(0.005) = %v, expected %v (should default to 100x)",
|
||||||
|
result, expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTempsWithPanicRecovery(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
systemInfo: system.Info{},
|
||||||
|
sensorConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
getTempsFn getTempsFn
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful_function_call",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
return []sensors.TemperatureStat{
|
||||||
|
{SensorKey: "test_sensor", Temperature: 45.0},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_returns_error",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
return []sensors.TemperatureStat{
|
||||||
|
{SensorKey: "test_sensor", Temperature: 45.0},
|
||||||
|
}, fmt.Errorf("sensor error")
|
||||||
|
},
|
||||||
|
expectError: false, // getTempsWithPanicRecovery ignores errors from the function
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_panics_with_string",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
panic("test panic")
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "panic: test panic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_panics_with_error",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
panic(fmt.Errorf("panic error"))
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "panic:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_panics_with_index_out_of_bounds",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
slice := []int{1, 2, 3}
|
||||||
|
_ = slice[10] // out of bounds panic
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "panic:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_panics_with_any_conversion",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
var i any = "string"
|
||||||
|
_ = i.(int) // type assertion panic
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "panic:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var temps []sensors.TemperatureStat
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// The function should not panic, regardless of what the injected function does
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
temps, err = agent.getTempsWithPanicRecovery(tt.getTempsFn)
|
||||||
|
}, "getTempsWithPanicRecovery should not panic")
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err, "Expected an error to be returned")
|
||||||
|
if tt.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errorMsg,
|
||||||
|
"Error message should contain expected text")
|
||||||
|
}
|
||||||
|
assert.Nil(t, temps, "Temps should be nil when panic occurs")
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err, "Should not return error for successful calls")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
281
beszel/internal/agent/sensors_windows.go
Normal file
281
beszel/internal/agent/sensors_windows.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
//go:generate dotnet build -c Release lhm/beszel_lhm.csproj
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: This is always called from Agent.gatherStats() which holds Agent.Lock(),
|
||||||
|
// so no internal concurrency protection is needed.
|
||||||
|
|
||||||
|
// lhmProcess is a wrapper around the LHM .NET process.
|
||||||
|
type lhmProcess struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout io.ReadCloser
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
isRunning bool
|
||||||
|
stoppedNoSensors bool
|
||||||
|
consecutiveNoSensors uint8
|
||||||
|
execPath string
|
||||||
|
tempDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed all:lhm/bin/Release/net48
|
||||||
|
var lhmFs embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
beszelLhm *lhmProcess
|
||||||
|
beszelLhmOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoSensors = errors.New("no sensors found (try running as admin)")
|
||||||
|
|
||||||
|
// newlhmProcess copies the embedded LHM executable to a temporary directory and starts it.
|
||||||
|
func newlhmProcess() (*lhmProcess, error) {
|
||||||
|
destDir := filepath.Join(os.TempDir(), "beszel")
|
||||||
|
execPath := filepath.Join(destDir, "beszel_lhm.exe")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only copy if executable doesn't exist
|
||||||
|
if _, err := os.Stat(execPath); os.IsNotExist(err) {
|
||||||
|
if err := copyEmbeddedDir(lhmFs, "lhm/bin/Release/net48", destDir); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to copy embedded directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lhm := &lhmProcess{
|
||||||
|
execPath: execPath,
|
||||||
|
tempDir: destDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lhm.startProcess(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start process: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lhm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startProcess starts the external LHM process
|
||||||
|
func (lhm *lhmProcess) startProcess() error {
|
||||||
|
// Clean up any existing process
|
||||||
|
lhm.cleanupProcess()
|
||||||
|
|
||||||
|
cmd := exec.Command(lhm.execPath)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
stdout.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update process state
|
||||||
|
lhm.cmd = cmd
|
||||||
|
lhm.stdin = stdin
|
||||||
|
lhm.stdout = stdout
|
||||||
|
lhm.scanner = bufio.NewScanner(stdout)
|
||||||
|
lhm.isRunning = true
|
||||||
|
|
||||||
|
// Give process a moment to initialize
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupProcess terminates the process and closes resources but preserves files
|
||||||
|
func (lhm *lhmProcess) cleanupProcess() {
|
||||||
|
lhm.isRunning = false
|
||||||
|
|
||||||
|
if lhm.cmd != nil && lhm.cmd.Process != nil {
|
||||||
|
lhm.cmd.Process.Kill()
|
||||||
|
lhm.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
if lhm.stdin != nil {
|
||||||
|
lhm.stdin.Close()
|
||||||
|
lhm.stdin = nil
|
||||||
|
}
|
||||||
|
if lhm.stdout != nil {
|
||||||
|
lhm.stdout.Close()
|
||||||
|
lhm.stdout = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lhm.cmd = nil
|
||||||
|
lhm.scanner = nil
|
||||||
|
lhm.stoppedNoSensors = false
|
||||||
|
lhm.consecutiveNoSensors = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
|
if lhm.stoppedNoSensors {
|
||||||
|
// Fall back to gopsutil if we can't get sensors from LHM
|
||||||
|
return sensors.TemperaturesWithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start process if it's not running
|
||||||
|
if !lhm.isRunning || lhm.stdin == nil || lhm.scanner == nil {
|
||||||
|
err := lhm.startProcess()
|
||||||
|
if err != nil {
|
||||||
|
return temps, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command to process
|
||||||
|
_, err = fmt.Fprintln(lhm.stdin, "getTemps")
|
||||||
|
if err != nil {
|
||||||
|
lhm.isRunning = false
|
||||||
|
return temps, fmt.Errorf("failed to send command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all sensor lines until we hit an empty line or EOF
|
||||||
|
for lhm.scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(lhm.scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(line, "|")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
slog.Debug("Invalid sensor format", "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(parts[0])
|
||||||
|
valueStr := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
value, err := strconv.ParseFloat(valueStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Failed to parse sensor", "err", err, "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" || value <= 0 || value > 150 {
|
||||||
|
slog.Debug("Invalid sensor", "name", name, "val", value, "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
temps = append(temps, sensors.TemperatureStat{
|
||||||
|
SensorKey: name,
|
||||||
|
Temperature: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lhm.scanner.Err(); err != nil {
|
||||||
|
lhm.isRunning = false
|
||||||
|
return temps, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle no sensors case
|
||||||
|
if len(temps) == 0 {
|
||||||
|
lhm.consecutiveNoSensors++
|
||||||
|
if lhm.consecutiveNoSensors >= 3 {
|
||||||
|
lhm.stoppedNoSensors = true
|
||||||
|
slog.Warn(errNoSensors.Error())
|
||||||
|
lhm.cleanup()
|
||||||
|
}
|
||||||
|
return sensors.TemperaturesWithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
lhm.consecutiveNoSensors = 0
|
||||||
|
|
||||||
|
return temps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
|
||||||
|
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
|
||||||
|
func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Error reading sensors", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Initialize process once
|
||||||
|
beszelLhmOnce.Do(func() {
|
||||||
|
beszelLhm, err = newlhmProcess()
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return temps, fmt.Errorf("failed to initialize lhm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if beszelLhm == nil {
|
||||||
|
return temps, fmt.Errorf("lhm not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return beszelLhm.getTemps(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup terminates the process and closes resources
|
||||||
|
func (lhm *lhmProcess) cleanup() {
|
||||||
|
lhm.cleanupProcess()
|
||||||
|
if lhm.tempDir != "" {
|
||||||
|
os.RemoveAll(lhm.tempDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEmbeddedDir copies the embedded directory to the destination path
|
||||||
|
func copyEmbeddedDir(fs embed.FS, srcPath, destPath string) error {
|
||||||
|
entries, err := fs.ReadDir(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
srcEntryPath := path.Join(srcPath, entry.Name())
|
||||||
|
destEntryPath := filepath.Join(destPath, entry.Name())
|
||||||
|
|
||||||
|
if entry.IsDir() {
|
||||||
|
if err := copyEmbeddedDir(fs, srcEntryPath, destEntryPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := fs.ReadFile(srcEntryPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(destEntryPath, data, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,25 +1,43 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
sshServer "github.com/gliderlabs/ssh"
|
"github.com/blang/semver"
|
||||||
"golang.org/x/crypto/ssh"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/gliderlabs/ssh"
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ServerOptions contains configuration options for starting the SSH server.
|
||||||
type ServerOptions struct {
|
type ServerOptions struct {
|
||||||
Addr string
|
Addr string // Network address to listen on (e.g., ":45876" or "/path/to/socket")
|
||||||
Network string
|
Network string // Network type ("tcp" or "unix")
|
||||||
Keys []ssh.PublicKey
|
Keys []gossh.PublicKey // SSH public keys for authentication
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hubVersions caches hub versions by session ID to avoid repeated parsing.
|
||||||
|
var hubVersions map[string]semver.Version
|
||||||
|
|
||||||
|
// StartServer starts the SSH server with the provided options.
|
||||||
|
// It configures the server with secure defaults, sets up authentication,
|
||||||
|
// and begins listening for connections. Returns an error if the server
|
||||||
|
// is already running or if there's an issue starting the server.
|
||||||
func (a *Agent) StartServer(opts ServerOptions) error {
|
func (a *Agent) StartServer(opts ServerOptions) error {
|
||||||
sshServer.Handle(a.handleSession)
|
if a.server != nil {
|
||||||
|
return errors.New("server already started")
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
|
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
|
||||||
|
|
||||||
@@ -37,33 +55,109 @@ func (a *Agent) StartServer(opts ServerOptions) error {
|
|||||||
}
|
}
|
||||||
defer ln.Close()
|
defer ln.Close()
|
||||||
|
|
||||||
// Start SSH server on the listener
|
// base config (limit to allowed algorithms)
|
||||||
return sshServer.Serve(ln, nil, sshServer.NoPty(),
|
config := &gossh.ServerConfig{
|
||||||
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
|
ServerVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
|
||||||
|
}
|
||||||
|
config.KeyExchanges = common.DefaultKeyExchanges
|
||||||
|
config.MACs = common.DefaultMACs
|
||||||
|
config.Ciphers = common.DefaultCiphers
|
||||||
|
|
||||||
|
// set default handler
|
||||||
|
ssh.Handle(a.handleSession)
|
||||||
|
|
||||||
|
a.server = &ssh.Server{
|
||||||
|
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
// check public key(s)
|
||||||
|
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||||
|
remoteAddr := ctx.RemoteAddr()
|
||||||
for _, pubKey := range opts.Keys {
|
for _, pubKey := range opts.Keys {
|
||||||
if sshServer.KeysEqual(key, pubKey) {
|
if ssh.KeysEqual(key, pubKey) {
|
||||||
|
slog.Info("SSH connected", "addr", remoteAddr)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
slog.Warn("Invalid SSH key", "addr", remoteAddr)
|
||||||
return false
|
return false
|
||||||
}),
|
},
|
||||||
)
|
// disable pty
|
||||||
|
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
// close idle connections after 70 seconds
|
||||||
|
IdleTimeout: 70 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start SSH server on the listener
|
||||||
|
return a.server.Serve(ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleSession(s sshServer.Session) {
|
// getHubVersion retrieves and caches the hub version for a given session.
|
||||||
slog.Debug("New session", "client", s.RemoteAddr())
|
// It extracts the version from the SSH client version string and caches
|
||||||
stats := a.gatherStats(s.Context().SessionID())
|
// it to avoid repeated parsing. Returns a zero version if parsing fails.
|
||||||
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
|
||||||
|
if hubVersions == nil {
|
||||||
|
hubVersions = make(map[string]semver.Version, 1)
|
||||||
|
}
|
||||||
|
hubVersion, ok := hubVersions[sessionId]
|
||||||
|
if ok {
|
||||||
|
return hubVersion
|
||||||
|
}
|
||||||
|
// Extract hub version from SSH client version
|
||||||
|
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
|
||||||
|
if versionStr, ok := clientVersion.(string); ok {
|
||||||
|
hubVersion, _ = extractHubVersion(versionStr)
|
||||||
|
}
|
||||||
|
hubVersions[sessionId] = hubVersion
|
||||||
|
return hubVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSession handles an incoming SSH session by gathering system statistics
|
||||||
|
// and sending them to the hub. It signals connection events, determines the
|
||||||
|
// appropriate encoding format based on hub version, and exits with appropriate
|
||||||
|
// status codes.
|
||||||
|
func (a *Agent) handleSession(s ssh.Session) {
|
||||||
|
a.connectionManager.eventChan <- SSHConnect
|
||||||
|
|
||||||
|
sessionCtx := s.Context()
|
||||||
|
sessionID := sessionCtx.SessionID()
|
||||||
|
|
||||||
|
hubVersion := a.getHubVersion(sessionID, sessionCtx)
|
||||||
|
|
||||||
|
stats := a.gatherStats(sessionID)
|
||||||
|
|
||||||
|
err := a.writeToSession(s, stats, hubVersion)
|
||||||
|
if err != nil {
|
||||||
slog.Error("Error encoding stats", "err", err, "stats", stats)
|
slog.Error("Error encoding stats", "err", err, "stats", stats)
|
||||||
s.Exit(1)
|
s.Exit(1)
|
||||||
|
} else {
|
||||||
|
s.Exit(0)
|
||||||
}
|
}
|
||||||
s.Exit(0)
|
}
|
||||||
|
|
||||||
|
// writeToSession encodes and writes system statistics to the session.
|
||||||
|
// It chooses between CBOR and JSON encoding based on the hub version,
|
||||||
|
// using CBOR for newer versions and JSON for legacy compatibility.
|
||||||
|
func (a *Agent) writeToSession(w io.Writer, stats *system.CombinedData, hubVersion semver.Version) error {
|
||||||
|
if hubVersion.GTE(beszel.MinVersionCbor) {
|
||||||
|
return cbor.NewEncoder(w).Encode(stats)
|
||||||
|
}
|
||||||
|
return json.NewEncoder(w).Encode(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHubVersion extracts the beszel version from SSH client version string.
|
||||||
|
// Expected format: "SSH-2.0-beszel_X.Y.Z" or "beszel_X.Y.Z"
|
||||||
|
func extractHubVersion(versionString string) (semver.Version, error) {
|
||||||
|
_, after, _ := strings.Cut(versionString, "_")
|
||||||
|
return semver.Parse(after)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseKeys parses a string containing SSH public keys in authorized_keys format.
|
// ParseKeys parses a string containing SSH public keys in authorized_keys format.
|
||||||
// It returns a slice of ssh.PublicKey and an error if any key fails to parse.
|
// It returns a slice of ssh.PublicKey and an error if any key fails to parse.
|
||||||
func ParseKeys(input string) ([]ssh.PublicKey, error) {
|
func ParseKeys(input string) ([]gossh.PublicKey, error) {
|
||||||
var parsedKeys []ssh.PublicKey
|
var parsedKeys []gossh.PublicKey
|
||||||
for line := range strings.Lines(input) {
|
for line := range strings.Lines(input) {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
// Skip empty lines or comments
|
// Skip empty lines or comments
|
||||||
@@ -71,7 +165,7 @@ func ParseKeys(input string) ([]ssh.PublicKey, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Parse the key
|
// Parse the key
|
||||||
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
|
parsedKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
|
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
|
||||||
}
|
}
|
||||||
@@ -80,7 +174,9 @@ func ParseKeys(input string) ([]ssh.PublicKey, error) {
|
|||||||
return parsedKeys, nil
|
return parsedKeys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAddress gets the address to listen on or connect to from environment variables or default value.
|
// GetAddress determines the network address to listen on from various sources.
|
||||||
|
// It checks the provided address, then environment variables (LISTEN, PORT),
|
||||||
|
// and finally defaults to ":45876".
|
||||||
func GetAddress(addr string) string {
|
func GetAddress(addr string) string {
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr, _ = GetEnv("LISTEN")
|
addr, _ = GetEnv("LISTEN")
|
||||||
@@ -99,7 +195,9 @@ func GetAddress(addr string) string {
|
|||||||
return addr
|
return addr
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNetwork returns the network type to use based on the address
|
// GetNetwork determines the network type based on the address format.
|
||||||
|
// It checks the NETWORK environment variable first, then infers from
|
||||||
|
// the address format: addresses starting with "/" are "unix", others are "tcp".
|
||||||
func GetNetwork(addr string) string {
|
func GetNetwork(addr string) string {
|
||||||
if network, ok := GetEnv("NETWORK"); ok && network != "" {
|
if network, ok := GetEnv("NETWORK"); ok && network != "" {
|
||||||
return network
|
return network
|
||||||
@@ -109,3 +207,17 @@ func GetNetwork(addr string) string {
|
|||||||
}
|
}
|
||||||
return "tcp"
|
return "tcp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StopServer stops the SSH server if it's running.
|
||||||
|
// It returns an error if the server is not running or if there's an error stopping it.
|
||||||
|
func (a *Agent) StopServer() error {
|
||||||
|
if a.server == nil {
|
||||||
|
return errors.New("SSH server not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Stopping SSH server")
|
||||||
|
_ = a.server.Close()
|
||||||
|
a.server = nil
|
||||||
|
a.connectionManager.eventChan <- SSHDisconnect
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,34 +1,43 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/gliderlabs/ssh"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStartServer(t *testing.T) {
|
func TestStartServer(t *testing.T) {
|
||||||
// Generate a test key pair
|
// Generate a test key pair
|
||||||
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
signer, err := ssh.NewSignerFromKey(privKey)
|
signer, err := gossh.NewSignerFromKey(privKey)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
sshPubKey, err := ssh.NewPublicKey(pubKey)
|
sshPubKey, err := gossh.NewPublicKey(pubKey)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Generate a different key pair for bad key test
|
// Generate a different key pair for bad key test
|
||||||
badPubKey, badPrivKey, err := ed25519.GenerateKey(nil)
|
badPubKey, badPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
badSigner, err := ssh.NewSignerFromKey(badPrivKey)
|
badSigner, err := gossh.NewSignerFromKey(badPrivKey)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
sshBadPubKey, err := ssh.NewPublicKey(badPubKey)
|
sshBadPubKey, err := gossh.NewPublicKey(badPubKey)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
socketFile := filepath.Join(t.TempDir(), "beszel-test.sock")
|
socketFile := filepath.Join(t.TempDir(), "beszel-test.sock")
|
||||||
@@ -46,7 +55,7 @@ func TestStartServer(t *testing.T) {
|
|||||||
config: ServerOptions{
|
config: ServerOptions{
|
||||||
Network: "tcp",
|
Network: "tcp",
|
||||||
Addr: ":45987",
|
Addr: ":45987",
|
||||||
Keys: []ssh.PublicKey{sshPubKey},
|
Keys: []gossh.PublicKey{sshPubKey},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -54,7 +63,7 @@ func TestStartServer(t *testing.T) {
|
|||||||
config: ServerOptions{
|
config: ServerOptions{
|
||||||
Network: "tcp4",
|
Network: "tcp4",
|
||||||
Addr: "127.0.0.1:45988",
|
Addr: "127.0.0.1:45988",
|
||||||
Keys: []ssh.PublicKey{sshPubKey},
|
Keys: []gossh.PublicKey{sshPubKey},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -62,7 +71,7 @@ func TestStartServer(t *testing.T) {
|
|||||||
config: ServerOptions{
|
config: ServerOptions{
|
||||||
Network: "tcp6",
|
Network: "tcp6",
|
||||||
Addr: "[::1]:45989",
|
Addr: "[::1]:45989",
|
||||||
Keys: []ssh.PublicKey{sshPubKey},
|
Keys: []gossh.PublicKey{sshPubKey},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,7 +79,7 @@ func TestStartServer(t *testing.T) {
|
|||||||
config: ServerOptions{
|
config: ServerOptions{
|
||||||
Network: "unix",
|
Network: "unix",
|
||||||
Addr: socketFile,
|
Addr: socketFile,
|
||||||
Keys: []ssh.PublicKey{sshPubKey},
|
Keys: []gossh.PublicKey{sshPubKey},
|
||||||
},
|
},
|
||||||
setup: func() error {
|
setup: func() error {
|
||||||
// Create a socket file that should be removed
|
// Create a socket file that should be removed
|
||||||
@@ -89,7 +98,7 @@ func TestStartServer(t *testing.T) {
|
|||||||
config: ServerOptions{
|
config: ServerOptions{
|
||||||
Network: "tcp",
|
Network: "tcp",
|
||||||
Addr: ":45987",
|
Addr: ":45987",
|
||||||
Keys: []ssh.PublicKey{sshBadPubKey},
|
Keys: []gossh.PublicKey{sshBadPubKey},
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errContains: "ssh: handshake failed",
|
errContains: "ssh: handshake failed",
|
||||||
@@ -99,7 +108,7 @@ func TestStartServer(t *testing.T) {
|
|||||||
config: ServerOptions{
|
config: ServerOptions{
|
||||||
Network: "tcp",
|
Network: "tcp",
|
||||||
Addr: ":45987",
|
Addr: ":45987",
|
||||||
Keys: []ssh.PublicKey{sshPubKey},
|
Keys: []gossh.PublicKey{sshPubKey},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -115,7 +124,8 @@ func TestStartServer(t *testing.T) {
|
|||||||
defer tt.cleanup()
|
defer tt.cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
agent := NewAgent()
|
agent, err := NewAgent("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Start server in a goroutine since it blocks
|
// Start server in a goroutine since it blocks
|
||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
@@ -127,8 +137,7 @@ func TestStartServer(t *testing.T) {
|
|||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
// Try to connect to verify server is running
|
// Try to connect to verify server is running
|
||||||
var client *ssh.Client
|
var client *gossh.Client
|
||||||
var err error
|
|
||||||
|
|
||||||
// Choose the appropriate signer based on the test case
|
// Choose the appropriate signer based on the test case
|
||||||
testSigner := signer
|
testSigner := signer
|
||||||
@@ -136,23 +145,23 @@ func TestStartServer(t *testing.T) {
|
|||||||
testSigner = badSigner
|
testSigner = badSigner
|
||||||
}
|
}
|
||||||
|
|
||||||
sshClientConfig := &ssh.ClientConfig{
|
sshClientConfig := &gossh.ClientConfig{
|
||||||
User: "a",
|
User: "a",
|
||||||
Auth: []ssh.AuthMethod{
|
Auth: []gossh.AuthMethod{
|
||||||
ssh.PublicKeys(testSigner),
|
gossh.PublicKeys(testSigner),
|
||||||
},
|
},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||||
Timeout: 4 * time.Second,
|
Timeout: 4 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch tt.config.Network {
|
switch tt.config.Network {
|
||||||
case "unix":
|
case "unix":
|
||||||
client, err = ssh.Dial("unix", tt.config.Addr, sshClientConfig)
|
client, err = gossh.Dial("unix", tt.config.Addr, sshClientConfig)
|
||||||
default:
|
default:
|
||||||
if !strings.Contains(tt.config.Addr, ":") {
|
if !strings.Contains(tt.config.Addr, ":") {
|
||||||
tt.config.Addr = ":" + tt.config.Addr
|
tt.config.Addr = ":" + tt.config.Addr
|
||||||
}
|
}
|
||||||
client, err = ssh.Dial("tcp", tt.config.Addr, sshClientConfig)
|
client, err = gossh.Dial("tcp", tt.config.Addr, sshClientConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
@@ -287,3 +296,310 @@ func TestParseInvalidKey(t *testing.T) {
|
|||||||
t.Fatalf("Expected error message to contain '%s', got: %v", expectedErrMsg, err)
|
t.Fatalf("Expected error message to contain '%s', got: %v", expectedErrMsg, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////// Hub Version Tests //////////////////////////
|
||||||
|
/////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestExtractHubVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
clientVersion string
|
||||||
|
expectedVersion string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid beszel client version with underscore",
|
||||||
|
clientVersion: "SSH-2.0-beszel_0.11.1",
|
||||||
|
expectedVersion: "0.11.1",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid beszel client version with beta",
|
||||||
|
clientVersion: "SSH-2.0-beszel_1.0.0-beta",
|
||||||
|
expectedVersion: "1.0.0-beta",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid beszel client version with rc",
|
||||||
|
clientVersion: "SSH-2.0-beszel_0.12.0-rc1",
|
||||||
|
expectedVersion: "0.12.0-rc1",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different SSH client",
|
||||||
|
clientVersion: "SSH-2.0-OpenSSH_8.0",
|
||||||
|
expectedVersion: "8.0",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed version string without underscore",
|
||||||
|
clientVersion: "SSH-2.0-beszel",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty version string",
|
||||||
|
clientVersion: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version string with underscore but no version",
|
||||||
|
clientVersion: "beszel_",
|
||||||
|
expectedVersion: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version with patch and build metadata",
|
||||||
|
clientVersion: "SSH-2.0-beszel_1.2.3+build.123",
|
||||||
|
expectedVersion: "1.2.3+build.123",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := extractHubVersion(tt.clientVersion)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expectedVersion, result.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////
|
||||||
|
/////////////// Hub Version Detection Tests ////////////////////
|
||||||
|
/////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestGetHubVersion(t *testing.T) {
|
||||||
|
agent, err := NewAgent("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Mock SSH context that implements the ssh.Context interface
|
||||||
|
mockCtx := &mockSSHContext{
|
||||||
|
sessionID: "test-session-123",
|
||||||
|
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test first call - should extract and cache version
|
||||||
|
version := agent.getHubVersion("test-session-123", mockCtx)
|
||||||
|
assert.Equal(t, "0.12.0", version.String())
|
||||||
|
|
||||||
|
// Test second call - should return cached version
|
||||||
|
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached
|
||||||
|
version = agent.getHubVersion("test-session-123", mockCtx)
|
||||||
|
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
|
||||||
|
|
||||||
|
// Test different session - should extract new version
|
||||||
|
version = agent.getHubVersion("different-session", mockCtx)
|
||||||
|
assert.Equal(t, "0.11.0", version.String())
|
||||||
|
|
||||||
|
// Test with invalid version string (non-beszel client)
|
||||||
|
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
|
||||||
|
version = agent.getHubVersion("invalid-session", mockCtx)
|
||||||
|
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
|
||||||
|
|
||||||
|
// Test with no client version
|
||||||
|
mockCtx.clientVersion = ""
|
||||||
|
version = agent.getHubVersion("no-version-session", mockCtx)
|
||||||
|
assert.True(t, version.EQ(semver.Version{})) // Should be empty version
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockSSHContext implements ssh.Context for testing
|
||||||
|
type mockSSHContext struct {
|
||||||
|
context.Context
|
||||||
|
sync.Mutex
|
||||||
|
sessionID string
|
||||||
|
clientVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHContext) SessionID() string {
|
||||||
|
return m.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHContext) ClientVersion() string {
|
||||||
|
return m.clientVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHContext) ServerVersion() string {
|
||||||
|
return "SSH-2.0-beszel_test"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHContext) Value(key interface{}) interface{} {
|
||||||
|
if key == ssh.ContextKeyClientVersion {
|
||||||
|
return m.clientVersion
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHContext) User() string { return "test-user" }
|
||||||
|
func (m *mockSSHContext) RemoteAddr() net.Addr { return nil }
|
||||||
|
func (m *mockSSHContext) LocalAddr() net.Addr { return nil }
|
||||||
|
func (m *mockSSHContext) Permissions() *ssh.Permissions { return nil }
|
||||||
|
func (m *mockSSHContext) SetValue(key, value interface{}) {}
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////
|
||||||
|
/////////////// CBOR vs JSON Encoding Tests ////////////////////
|
||||||
|
/////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// TestWriteToSessionEncoding tests that writeToSession actually encodes data in the correct format
|
||||||
|
func TestWriteToSessionEncoding(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hubVersion string
|
||||||
|
expectedUsesCbor bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "old hub version should use JSON",
|
||||||
|
hubVersion: "0.11.1",
|
||||||
|
expectedUsesCbor: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-beta release should use CBOR",
|
||||||
|
hubVersion: "0.12.0",
|
||||||
|
expectedUsesCbor: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "even newer hub version should use CBOR",
|
||||||
|
hubVersion: "0.16.4",
|
||||||
|
expectedUsesCbor: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "beta version below release threshold should use JSON",
|
||||||
|
hubVersion: "0.12.0-beta0",
|
||||||
|
expectedUsesCbor: false,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// name: "matching beta version should use CBOR",
|
||||||
|
// hubVersion: "0.12.0-beta2",
|
||||||
|
// expectedUsesCbor: true,
|
||||||
|
// },
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Reset the global hubVersions map to ensure clean state for each test
|
||||||
|
hubVersions = nil
|
||||||
|
|
||||||
|
agent, err := NewAgent("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse the test version
|
||||||
|
version, err := semver.Parse(tt.hubVersion)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create test data to encode
|
||||||
|
testData := createTestCombinedData()
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
err = agent.writeToSession(&buf, testData, version)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
encodedData := buf.String()
|
||||||
|
require.NotEmpty(t, encodedData)
|
||||||
|
|
||||||
|
// Verify the encoding format by attempting to decode
|
||||||
|
if tt.expectedUsesCbor {
|
||||||
|
var decodedCbor system.CombinedData
|
||||||
|
err = cbor.Unmarshal([]byte(encodedData), &decodedCbor)
|
||||||
|
assert.NoError(t, err, "Should be valid CBOR data")
|
||||||
|
|
||||||
|
var decodedJson system.CombinedData
|
||||||
|
err = json.Unmarshal([]byte(encodedData), &decodedJson)
|
||||||
|
assert.Error(t, err, "Should not be valid JSON data")
|
||||||
|
|
||||||
|
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname)
|
||||||
|
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
|
||||||
|
} else {
|
||||||
|
// Should be JSON - try to decode as JSON
|
||||||
|
var decodedJson system.CombinedData
|
||||||
|
err = json.Unmarshal([]byte(encodedData), &decodedJson)
|
||||||
|
assert.NoError(t, err, "Should be valid JSON data")
|
||||||
|
|
||||||
|
var decodedCbor system.CombinedData
|
||||||
|
err = cbor.Unmarshal([]byte(encodedData), &decodedCbor)
|
||||||
|
assert.Error(t, err, "Should not be valid CBOR data")
|
||||||
|
|
||||||
|
// Verify the decoded JSON data matches our test data
|
||||||
|
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
|
||||||
|
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
|
||||||
|
|
||||||
|
// Verify it looks like JSON (starts with '{' and contains readable field names)
|
||||||
|
assert.True(t, strings.HasPrefix(encodedData, "{"), "JSON should start with '{'")
|
||||||
|
assert.Contains(t, encodedData, `"info"`, "JSON should contain readable field names")
|
||||||
|
assert.Contains(t, encodedData, `"stats"`, "JSON should contain readable field names")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create test data for encoding tests
|
||||||
|
func createTestCombinedData() *system.CombinedData {
|
||||||
|
return &system.CombinedData{
|
||||||
|
Stats: system.Stats{
|
||||||
|
Cpu: 25.5,
|
||||||
|
Mem: 8589934592, // 8GB
|
||||||
|
MemUsed: 4294967296, // 4GB
|
||||||
|
MemPct: 50.0,
|
||||||
|
DiskTotal: 1099511627776, // 1TB
|
||||||
|
DiskUsed: 549755813888, // 512GB
|
||||||
|
DiskPct: 50.0,
|
||||||
|
},
|
||||||
|
Info: system.Info{
|
||||||
|
Hostname: "test-host",
|
||||||
|
Cores: 8,
|
||||||
|
CpuModel: "Test CPU Model",
|
||||||
|
Uptime: 3600,
|
||||||
|
AgentVersion: "0.12.0",
|
||||||
|
Os: system.Linux,
|
||||||
|
},
|
||||||
|
Containers: []*container.Stats{
|
||||||
|
{
|
||||||
|
Name: "test-container",
|
||||||
|
Cpu: 10.5,
|
||||||
|
Mem: 1073741824, // 1GB
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubVersionCaching(t *testing.T) {
|
||||||
|
// Reset the global hubVersions map to ensure clean state
|
||||||
|
hubVersions = nil
|
||||||
|
|
||||||
|
agent, err := NewAgent("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx1 := &mockSSHContext{
|
||||||
|
sessionID: "session1",
|
||||||
|
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||||
|
}
|
||||||
|
ctx2 := &mockSSHContext{
|
||||||
|
sessionID: "session2",
|
||||||
|
clientVersion: "SSH-2.0-beszel_0.11.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
// First calls should cache the versions
|
||||||
|
v1 := agent.getHubVersion("session1", ctx1)
|
||||||
|
v2 := agent.getHubVersion("session2", ctx2)
|
||||||
|
|
||||||
|
assert.Equal(t, "0.12.0", v1.String())
|
||||||
|
assert.Equal(t, "0.11.0", v2.String())
|
||||||
|
|
||||||
|
// Verify caching by changing context but keeping same session ID
|
||||||
|
ctx1.clientVersion = "SSH-2.0-beszel_0.10.0"
|
||||||
|
v1Cached := agent.getHubVersion("session1", ctx1)
|
||||||
|
assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version
|
||||||
|
|
||||||
|
// New session should get new version
|
||||||
|
ctx3 := &mockSSHContext{
|
||||||
|
sessionID: "session3",
|
||||||
|
clientVersion: "SSH-2.0-beszel_0.13.0",
|
||||||
|
}
|
||||||
|
v3 := agent.getHubVersion("session3", ctx3)
|
||||||
|
assert.Equal(t, "0.13.0", v3.String())
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
|
"beszel/internal/agent/battery"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
@@ -58,10 +60,10 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// zfs
|
// zfs
|
||||||
if _, err := getARCSize(); err == nil {
|
if _, err := getARCSize(); err != nil {
|
||||||
a.zfs = true
|
|
||||||
} else {
|
|
||||||
slog.Debug("Not monitoring ZFS ARC", "err", err)
|
slog.Debug("Not monitoring ZFS ARC", "err", err)
|
||||||
|
} else {
|
||||||
|
a.zfs = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +71,11 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
func (a *Agent) getSystemStats() system.Stats {
|
func (a *Agent) getSystemStats() system.Stats {
|
||||||
systemStats := system.Stats{}
|
systemStats := system.Stats{}
|
||||||
|
|
||||||
|
// battery
|
||||||
|
if battery.HasReadableBattery() {
|
||||||
|
systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats()
|
||||||
|
}
|
||||||
|
|
||||||
// cpu percent
|
// cpu percent
|
||||||
cpuPct, err := cpu.Percent(0, false)
|
cpuPct, err := cpu.Percent(0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,6 +84,16 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
systemStats.Cpu = twoDecimals(cpuPct[0])
|
systemStats.Cpu = twoDecimals(cpuPct[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load average
|
||||||
|
if avgstat, err := load.Avg(); err == nil {
|
||||||
|
systemStats.LoadAvg[0] = avgstat.Load1
|
||||||
|
systemStats.LoadAvg[1] = avgstat.Load5
|
||||||
|
systemStats.LoadAvg[2] = avgstat.Load15
|
||||||
|
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
|
||||||
|
} else {
|
||||||
|
slog.Error("Error getting load average", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
// memory
|
// memory
|
||||||
if v, err := mem.VirtualMemory(); err == nil {
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
// swap
|
// swap
|
||||||
@@ -163,24 +180,27 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
a.initializeNetIoStats()
|
a.initializeNetIoStats()
|
||||||
}
|
}
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
||||||
a.netIoStats.Time = time.Now()
|
a.netIoStats.Time = time.Now()
|
||||||
bytesSent := uint64(0)
|
totalBytesSent := uint64(0)
|
||||||
bytesRecv := uint64(0)
|
totalBytesRecv := uint64(0)
|
||||||
// sum all bytes sent and received
|
// sum all bytes sent and received
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
// skip if not in valid network interfaces list
|
// skip if not in valid network interfaces list
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bytesSent += v.BytesSent
|
totalBytesSent += v.BytesSent
|
||||||
bytesRecv += v.BytesRecv
|
totalBytesRecv += v.BytesRecv
|
||||||
}
|
}
|
||||||
// add to systemStats
|
// add to systemStats
|
||||||
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
|
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
||||||
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
|
if msElapsed > 0 {
|
||||||
networkSentPs := bytesToMegabytes(sentPerSecond)
|
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
||||||
networkRecvPs := bytesToMegabytes(recvPerSecond)
|
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
||||||
|
}
|
||||||
|
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||||
|
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||||
// add check for issue (#150) where sent is a massive number
|
// add check for issue (#150) where sent is a massive number
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
@@ -195,9 +215,10 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
} else {
|
} else {
|
||||||
systemStats.NetworkSent = networkSentPs
|
systemStats.NetworkSent = networkSentPs
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||||
// update netIoStats
|
// update netIoStats
|
||||||
a.netIoStats.BytesSent = bytesSent
|
a.netIoStats.BytesSent = totalBytesSent
|
||||||
a.netIoStats.BytesRecv = bytesRecv
|
a.netIoStats.BytesRecv = totalBytesRecv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +261,17 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
|
|
||||||
// update base system info
|
// update base system info
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
|
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||||
|
// TODO: remove these in future release in favor of load avg array
|
||||||
|
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
|
||||||
|
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
|
||||||
|
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
|
||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
|
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||||
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
|
|||||||
@@ -1,56 +1,150 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel/internal/ghupdate"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
|
||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update updates beszel-agent to the latest version
|
// restarter knows how to restart the beszel-agent service.
|
||||||
func Update() {
|
type restarter interface {
|
||||||
var latest *selfupdate.Release
|
Restart() error
|
||||||
var found bool
|
}
|
||||||
var err error
|
|
||||||
currentVersion := semver.MustParse(beszel.Version)
|
type systemdRestarter struct{ cmd string }
|
||||||
fmt.Println("beszel-agent", currentVersion)
|
|
||||||
fmt.Println("Checking for updates...")
|
func (s *systemdRestarter) Restart() error {
|
||||||
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
// Only restart if the service is active
|
||||||
Filters: []string{"beszel-agent"},
|
if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil {
|
||||||
})
|
return nil
|
||||||
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
}
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…")
|
||||||
if err != nil {
|
return exec.Command(s.cmd, "restart", "beszel-agent.service").Run()
|
||||||
fmt.Println("Error checking for updates:", err)
|
}
|
||||||
os.Exit(1)
|
|
||||||
}
|
type openRCRestarter struct{ cmd string }
|
||||||
|
|
||||||
if !found {
|
func (o *openRCRestarter) Restart() error {
|
||||||
fmt.Println("No updates found")
|
if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil {
|
||||||
os.Exit(0)
|
return nil
|
||||||
}
|
}
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
|
||||||
fmt.Println("Latest version:", latest.Version)
|
return exec.Command(o.cmd, "restart", "beszel-agent").Run()
|
||||||
|
}
|
||||||
if latest.Version.LTE(currentVersion) {
|
|
||||||
fmt.Println("You are up to date")
|
type openWRTRestarter struct{ cmd string }
|
||||||
return
|
|
||||||
}
|
func (w *openWRTRestarter) Restart() error {
|
||||||
|
if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil {
|
||||||
var binaryPath string
|
return nil
|
||||||
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
}
|
||||||
binaryPath, err = os.Executable()
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
|
||||||
if err != nil {
|
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
|
||||||
fmt.Println("Error getting binary path:", err)
|
}
|
||||||
os.Exit(1)
|
|
||||||
}
|
func detectRestarter() restarter {
|
||||||
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||||
if err != nil {
|
return &systemdRestarter{cmd: path}
|
||||||
fmt.Println("Please try rerunning with sudo. Error:", err)
|
}
|
||||||
os.Exit(1)
|
if path, err := exec.LookPath("rc-service"); err == nil {
|
||||||
}
|
return &openRCRestarter{cmd: path}
|
||||||
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
}
|
||||||
|
if path, err := exec.LookPath("service"); err == nil {
|
||||||
|
return &openWRTRestarter{cmd: path}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update checks GitHub for a newer release of beszel-agent, applies it,
|
||||||
|
// fixes SELinux context if needed, and restarts the service.
|
||||||
|
func Update(useMirror bool) error {
|
||||||
|
exePath, _ := os.Executable()
|
||||||
|
|
||||||
|
dataDir, err := getDataDir()
|
||||||
|
if err != nil {
|
||||||
|
dataDir = os.TempDir()
|
||||||
|
}
|
||||||
|
updated, err := ghupdate.Update(ghupdate.Config{
|
||||||
|
ArchiveExecutable: "beszel-agent",
|
||||||
|
DataDir: dataDir,
|
||||||
|
UseMirror: useMirror,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure the file is executable
|
||||||
|
if err := os.Chmod(exePath, 0755); err != nil {
|
||||||
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set executable permissions: %v", err)
|
||||||
|
}
|
||||||
|
// set ownership to beszel:beszel if possible
|
||||||
|
if chownPath, err := exec.LookPath("chown"); err == nil {
|
||||||
|
if err := exec.Command(chownPath, "beszel:beszel", exePath).Run(); err != nil {
|
||||||
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set file ownership: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Fix SELinux context if necessary
|
||||||
|
if err := handleSELinuxContext(exePath); err != nil {
|
||||||
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) Restart service if running under a recognised init system
|
||||||
|
if r := detectRestarter(); r != nil {
|
||||||
|
if err := r.Restart(); err != nil {
|
||||||
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.")
|
||||||
|
} else {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSELinuxContext restores or applies the correct SELinux label to the binary.
|
||||||
|
func handleSELinuxContext(path string) error {
|
||||||
|
out, err := exec.Command("getenforce").Output()
|
||||||
|
if err != nil {
|
||||||
|
// SELinux not enabled or getenforce not available
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state := strings.TrimSpace(string(out))
|
||||||
|
if state == "Disabled" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "SELinux is enabled; applying context…")
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
// Try persistent context via semanage+restorecon
|
||||||
|
if semanagePath, err := exec.LookPath("semanage"); err == nil {
|
||||||
|
if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil {
|
||||||
|
errs = append(errs, "semanage fcontext failed: "+err.Error())
|
||||||
|
} else if restoreconPath, err := exec.LookPath("restorecon"); err == nil {
|
||||||
|
if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil {
|
||||||
|
errs = append(errs, "restorecon failed: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to temporary context via chcon
|
||||||
|
if chconPath, err := exec.LookPath("chcon"); err == nil {
|
||||||
|
if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil {
|
||||||
|
errs = append(errs, "chcon failed: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import (
|
|||||||
|
|
||||||
"github.com/nicholas-fedor/shoutrrr"
|
"github.com/nicholas-fedor/shoutrrr"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type hubLike interface {
|
||||||
|
core.App
|
||||||
|
MakeLink(parts ...string) string
|
||||||
|
}
|
||||||
|
|
||||||
type AlertManager struct {
|
type AlertManager struct {
|
||||||
app core.App
|
hub hubLike
|
||||||
alertQueue chan alertTask
|
alertQueue chan alertTask
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
pendingAlerts sync.Map
|
pendingAlerts sync.Map
|
||||||
@@ -42,6 +46,7 @@ type SystemAlertStats struct {
|
|||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
|
LoadAvg [3]float64 `json:"la"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
@@ -79,19 +84,27 @@ var supportsTitle = map[string]struct{}{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAlertManager creates a new AlertManager instance.
|
// NewAlertManager creates a new AlertManager instance.
|
||||||
func NewAlertManager(app core.App) *AlertManager {
|
func NewAlertManager(app hubLike) *AlertManager {
|
||||||
am := &AlertManager{
|
am := &AlertManager{
|
||||||
app: app,
|
hub: app,
|
||||||
alertQueue: make(chan alertTask),
|
alertQueue: make(chan alertTask),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
am.bindEvents()
|
||||||
go am.startWorker()
|
go am.startWorker()
|
||||||
return am
|
return am
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind events to the alerts collection lifecycle
|
||||||
|
func (am *AlertManager) bindEvents() {
|
||||||
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.app.FindFirstRecordByFilter(
|
record, err := am.hub.FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
dbx.Params{"user": data.UserID},
|
dbx.Params{"user": data.UserID},
|
||||||
)
|
)
|
||||||
@@ -104,12 +117,12 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
Webhooks: []string{},
|
Webhooks: []string{},
|
||||||
}
|
}
|
||||||
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
|
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
|
||||||
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
|
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
|
||||||
}
|
}
|
||||||
// send alerts via webhooks
|
// send alerts via webhooks
|
||||||
for _, webhook := range userAlertSettings.Webhooks {
|
for _, webhook := range userAlertSettings.Webhooks {
|
||||||
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
|
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
|
||||||
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error())
|
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// send alerts via email
|
// send alerts via email
|
||||||
@@ -125,15 +138,15 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
Subject: data.Title,
|
Subject: data.Title,
|
||||||
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
|
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
|
||||||
From: mail.Address{
|
From: mail.Address{
|
||||||
Address: am.app.Settings().Meta.SenderAddress,
|
Address: am.hub.Settings().Meta.SenderAddress,
|
||||||
Name: am.app.Settings().Meta.SenderName,
|
Name: am.hub.Settings().Meta.SenderName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
err = am.app.NewMailClient().Send(&message)
|
err = am.hub.NewMailClient().Send(&message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
am.hub.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,25 +196,23 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
err = shoutrrr.Send(parsedURL.String(), message)
|
err = shoutrrr.Send(parsedURL.String(), message)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
|
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
|
||||||
} else {
|
} else {
|
||||||
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
|
am.hub.Logger().Error("Error sending shoutrrr alert", "err", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
info, _ := e.RequestInfo()
|
var data struct {
|
||||||
if info.Auth == nil {
|
URL string `json:"url"`
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
}
|
||||||
url := e.Request.URL.Query().Get("url")
|
err := e.BindBody(&data)
|
||||||
// log.Println("url", url)
|
if err != nil || data.URL == "" {
|
||||||
if url == "" {
|
return e.BadRequestError("URL is required", err)
|
||||||
return e.JSON(200, map[string]string{"err": "URL is required"})
|
|
||||||
}
|
}
|
||||||
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppURL, "View Beszel")
|
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
119
beszel/internal/alerts/alerts_api.go
Normal file
119
beszel/internal/alerts/alerts_api.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpsertUserAlerts handles API request to create or update alerts for a user
|
||||||
|
// across multiple systems (POST /api/beszel/user-alerts)
|
||||||
|
func UpsertUserAlerts(e *core.RequestEvent) error {
|
||||||
|
userID := e.Auth.Id
|
||||||
|
|
||||||
|
reqData := struct {
|
||||||
|
Min uint8 `json:"min"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Systems []string `json:"systems"`
|
||||||
|
Overwrite bool `json:"overwrite"`
|
||||||
|
}{}
|
||||||
|
err := e.BindBody(&reqData)
|
||||||
|
if err != nil || userID == "" || reqData.Name == "" || len(reqData.Systems) == 0 {
|
||||||
|
return e.BadRequestError("Bad data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alertsCollection, err := e.App.FindCachedCollectionByNameOrId("alerts")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
|
for _, systemId := range reqData.Systems {
|
||||||
|
// find existing matching alert
|
||||||
|
alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection,
|
||||||
|
"system={:system} && name={:name} && user={:user}",
|
||||||
|
dbx.Params{"system": systemId, "name": reqData.Name, "user": userID})
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip if alert already exists and overwrite is not set
|
||||||
|
if !reqData.Overwrite && alertRecord != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// create new alert if it doesn't exist
|
||||||
|
if alertRecord == nil {
|
||||||
|
alertRecord = core.NewRecord(alertsCollection)
|
||||||
|
alertRecord.Set("user", userID)
|
||||||
|
alertRecord.Set("system", systemId)
|
||||||
|
alertRecord.Set("name", reqData.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
alertRecord.Set("value", reqData.Value)
|
||||||
|
alertRecord.Set("min", reqData.Min)
|
||||||
|
|
||||||
|
if err := txApp.SaveNoValidate(alertRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserAlerts handles API request to delete alerts for a user across multiple systems
|
||||||
|
// (DELETE /api/beszel/user-alerts)
|
||||||
|
func DeleteUserAlerts(e *core.RequestEvent) error {
|
||||||
|
userID := e.Auth.Id
|
||||||
|
|
||||||
|
reqData := struct {
|
||||||
|
AlertName string `json:"name"`
|
||||||
|
Systems []string `json:"systems"`
|
||||||
|
}{}
|
||||||
|
err := e.BindBody(&reqData)
|
||||||
|
if err != nil || userID == "" || reqData.AlertName == "" || len(reqData.Systems) == 0 {
|
||||||
|
return e.BadRequestError("Bad data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var numDeleted uint16
|
||||||
|
|
||||||
|
err = e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
|
for _, systemId := range reqData.Systems {
|
||||||
|
// Find existing alert to delete
|
||||||
|
alertRecord, err := txApp.FindFirstRecordByFilter("alerts",
|
||||||
|
"system={:system} && name={:name} && user={:user}",
|
||||||
|
dbx.Params{"system": systemId, "name": reqData.AlertName, "user": userID})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// alert doesn't exist, continue to next system
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := txApp.Delete(alertRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
numDeleted++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
||||||
|
}
|
||||||
85
beszel/internal/alerts/alerts_history.go
Normal file
85
beszel/internal/alerts/alerts_history.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// On triggered alert record delete, set matching alert history record to resolved
|
||||||
|
func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
|
||||||
|
if !e.Record.GetBool("triggered") {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
_ = resolveAlertHistoryRecord(e.App, e.Record.Id)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// On alert record update, update alert history record
|
||||||
|
func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
|
||||||
|
original := e.Record.Original()
|
||||||
|
new := e.Record
|
||||||
|
|
||||||
|
originalTriggered := original.GetBool("triggered")
|
||||||
|
newTriggered := new.GetBool("triggered")
|
||||||
|
|
||||||
|
// no need to update alert history if triggered state has not changed
|
||||||
|
if originalTriggered == newTriggered {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// if new state is triggered, create new alert history record
|
||||||
|
if newTriggered {
|
||||||
|
_, _ = createAlertHistoryRecord(e.App, new)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// if new state is not triggered, check for matching alert history record and set it to resolved
|
||||||
|
_ = resolveAlertHistoryRecord(e.App, new.Id)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAlertHistoryRecord sets the resolved field to the current time
|
||||||
|
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
|
||||||
|
alertHistoryRecords, err := app.FindRecordsByFilter(
|
||||||
|
"alerts_history",
|
||||||
|
"alert_id={:alert_id} && resolved=null",
|
||||||
|
"-created",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
dbx.Params{"alert_id": alertRecordID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(alertHistoryRecords) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
|
||||||
|
alertHistoryRecord.Set("resolved", time.Now().UTC())
|
||||||
|
err = app.Save(alertHistoryRecord)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to resolve alert history", "err", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAlertHistoryRecord creates a new alert history record
|
||||||
|
func createAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHistoryRecord *core.Record, err error) {
|
||||||
|
alertHistoryCollection, err := app.FindCachedCollectionByNameOrId("alerts_history")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
alertHistoryRecord = core.NewRecord(alertHistoryCollection)
|
||||||
|
alertHistoryRecord.Set("alert_id", alertRecord.Id)
|
||||||
|
alertHistoryRecord.Set("user", alertRecord.GetString("user"))
|
||||||
|
alertHistoryRecord.Set("system", alertRecord.GetString("system"))
|
||||||
|
alertHistoryRecord.Set("name", alertRecord.GetString("name"))
|
||||||
|
alertHistoryRecord.Set("value", alertRecord.GetFloat("value"))
|
||||||
|
err = app.Save(alertHistoryRecord)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to save alert history", "err", err)
|
||||||
|
}
|
||||||
|
return alertHistoryRecord, err
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package alerts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.
|
|||||||
|
|
||||||
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
|
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
|
||||||
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
|
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
|
||||||
alertRecords, err := am.app.FindAllRecords("alerts", dbx.HashExp{
|
alertRecords, err := am.hub.FindAllRecords("alerts", dbx.HashExp{
|
||||||
"system": systemID,
|
"system": systemID,
|
||||||
"name": "Status",
|
"name": "Status",
|
||||||
})
|
})
|
||||||
@@ -130,13 +129,21 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
|
|||||||
}
|
}
|
||||||
// No alert scheduled for this record, send "up" alert
|
// No alert scheduled for this record, send "up" alert
|
||||||
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
|
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
|
||||||
am.app.Logger().Error("Failed to send alert", "err", err.Error())
|
am.hub.Logger().Error("Failed to send alert", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
||||||
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
||||||
|
switch alertStatus {
|
||||||
|
case "up":
|
||||||
|
alertRecord.Set("triggered", false)
|
||||||
|
case "down":
|
||||||
|
alertRecord.Set("triggered", true)
|
||||||
|
}
|
||||||
|
am.hub.Save(alertRecord)
|
||||||
|
|
||||||
var emoji string
|
var emoji string
|
||||||
if alertStatus == "up" {
|
if alertStatus == "up" {
|
||||||
emoji = "\u2705" // Green checkmark emoji
|
emoji = "\u2705" // Green checkmark emoji
|
||||||
@@ -147,19 +154,19 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
return errs["user"]
|
// return errs["user"]
|
||||||
}
|
// }
|
||||||
user := alertRecord.ExpandedOne("user")
|
// user := alertRecord.ExpandedOne("user")
|
||||||
if user == nil {
|
// if user == nil {
|
||||||
return nil
|
// return nil
|
||||||
}
|
// }
|
||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: user.Id,
|
UserID: alertRecord.GetString("user"),
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ package alerts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
@@ -15,8 +14,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
||||||
alertRecords, err := am.app.FindAllRecords("alerts",
|
alertRecords, err := am.hub.FindAllRecords("alerts",
|
||||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
dbx.NewExp("system={:system} AND name!='Status'", dbx.Params{"system": systemRecord.Id}),
|
||||||
)
|
)
|
||||||
if err != nil || len(alertRecords) == 0 {
|
if err != nil || len(alertRecords) == 0 {
|
||||||
// log.Println("no alerts found for system")
|
// log.Println("no alerts found for system")
|
||||||
@@ -55,6 +54,15 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
val = data.Info.DashboardTemp
|
val = data.Info.DashboardTemp
|
||||||
unit = "°C"
|
unit = "°C"
|
||||||
|
case "LoadAvg1":
|
||||||
|
val = data.Info.LoadAvg[0]
|
||||||
|
unit = ""
|
||||||
|
case "LoadAvg5":
|
||||||
|
val = data.Info.LoadAvg[1]
|
||||||
|
unit = ""
|
||||||
|
case "LoadAvg15":
|
||||||
|
val = data.Info.LoadAvg[2]
|
||||||
|
unit = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
triggered := alertRecord.GetBool("triggered")
|
||||||
@@ -101,7 +109,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
Created types.DateTime `db:"created"`
|
Created types.DateTime `db:"created"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
err = am.app.DB().
|
err = am.hub.DB().
|
||||||
Select("stats", "created").
|
Select("stats", "created").
|
||||||
From("system_stats").
|
From("system_stats").
|
||||||
Where(dbx.NewExp(
|
Where(dbx.NewExp(
|
||||||
@@ -191,6 +199,12 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
alert.mapSums[key] += temp
|
alert.mapSums[key] += temp
|
||||||
}
|
}
|
||||||
|
case "LoadAvg1":
|
||||||
|
alert.val += stats.LoadAvg[0]
|
||||||
|
case "LoadAvg5":
|
||||||
|
alert.val += stats.LoadAvg[1]
|
||||||
|
case "LoadAvg15":
|
||||||
|
alert.val += stats.LoadAvg[2]
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -248,6 +262,10 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
if alert.name == "Disk" {
|
if alert.name == "Disk" {
|
||||||
alert.name += " usage"
|
alert.name += " usage"
|
||||||
}
|
}
|
||||||
|
// format LoadAvg5 and LoadAvg15
|
||||||
|
if after, ok := strings.CutPrefix(alert.name, "LoadAvg"); ok {
|
||||||
|
alert.name = after + "m Load"
|
||||||
|
}
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU
|
// make title alert name lowercase if not CPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
@@ -271,22 +289,15 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
||||||
|
|
||||||
alert.alertRecord.Set("triggered", alert.triggered)
|
alert.alertRecord.Set("triggered", alert.triggered)
|
||||||
if err := am.app.Save(alert.alertRecord); err != nil {
|
if err := am.hub.Save(alert.alertRecord); err != nil {
|
||||||
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
// app.Logger().Error("failed to save alert record", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// expand the user relation and send the alert
|
am.SendAlert(AlertMessageData{
|
||||||
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
Title: subject,
|
||||||
return
|
Message: body,
|
||||||
}
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
LinkText: "View " + systemName,
|
||||||
am.SendAlert(AlertMessageData{
|
})
|
||||||
UserID: user.Id,
|
|
||||||
Title: subject,
|
|
||||||
Message: body,
|
|
||||||
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
|
||||||
LinkText: "View " + systemName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
368
beszel/internal/alerts/alerts_test.go
Normal file
368
beszel/internal/alerts/alerts_test.go
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
beszelTests "beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
|
func jsonReader(v any) io.Reader {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAlertsApi(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||||
|
user1Token, _ := user1.NewAuthToken()
|
||||||
|
|
||||||
|
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||||
|
user2Token, _ := user2.NewAuthToken()
|
||||||
|
|
||||||
|
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system1",
|
||||||
|
"users": []string{user1.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system2",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.2",
|
||||||
|
})
|
||||||
|
|
||||||
|
userRecords, _ := hub.CountRecords("users")
|
||||||
|
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||||
|
|
||||||
|
systemRecords, _ := hub.CountRecords("systems")
|
||||||
|
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "GET not implemented - returns index",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no auth",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no body",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST bad data",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"invalidField": "this should cause validation error",
|
||||||
|
"threshold": "not a number",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST malformed JSON",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data multiple systems",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 69,
|
||||||
|
"min": 9,
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
// check total alerts
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
// check alert has correct values
|
||||||
|
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||||
|
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data single system",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: false, should not overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: true, should overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
"overwrite": true,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user2.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE no auth",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert multiple systems",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||||
|
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"system": systemId,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "should create alert")
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "User 2 should not be able to delete alert of user 1",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, user := range []string{user1.Id, user2.Id} {
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
beszel/internal/common/common-ssh.go
Normal file
10
beszel/internal/common/common-ssh.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Allowed ssh key exchanges
|
||||||
|
DefaultKeyExchanges = []string{"curve25519-sha256"}
|
||||||
|
// Allowed ssh macs
|
||||||
|
DefaultMACs = []string{"hmac-sha2-256-etm@openssh.com"}
|
||||||
|
// Allowed ssh ciphers
|
||||||
|
DefaultCiphers = []string{"chacha20-poly1305@openssh.com"}
|
||||||
|
)
|
||||||
32
beszel/internal/common/common-ws.go
Normal file
32
beszel/internal/common/common-ws.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
type WebSocketAction = uint8
|
||||||
|
|
||||||
|
// Not implemented yet
|
||||||
|
// type AgentError = uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Request system data from agent
|
||||||
|
GetData WebSocketAction = iota
|
||||||
|
// Check the fingerprint of the agent
|
||||||
|
CheckFingerprint
|
||||||
|
)
|
||||||
|
|
||||||
|
// HubRequest defines the structure for requests sent from hub to agent.
|
||||||
|
type HubRequest[T any] struct {
|
||||||
|
Action WebSocketAction `cbor:"0,keyasint"`
|
||||||
|
Data T `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
|
// Error AgentError `cbor:"error,omitempty,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FingerprintRequest struct {
|
||||||
|
Signature []byte `cbor:"0,keyasint"`
|
||||||
|
NeedSysInfo bool `cbor:"1,keyasint"` // For universal token system creation
|
||||||
|
}
|
||||||
|
|
||||||
|
type FingerprintResponse struct {
|
||||||
|
Fingerprint string `cbor:"0,keyasint"`
|
||||||
|
// Optional system info for universal token system creation
|
||||||
|
Hostname string `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
|
Port string `cbor:"2,keyasint,omitempty,omitzero"`
|
||||||
|
}
|
||||||
@@ -34,10 +34,16 @@ type ApiStats struct {
|
|||||||
MemoryStats MemoryStats `json:"memory_stats"`
|
MemoryStats MemoryStats `json:"memory_stats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuUsage [2]uint64) float64 {
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
||||||
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage[0]
|
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
||||||
systemDelta := s.CPUStats.SystemUsage - prevCpuUsage[1]
|
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
||||||
return float64(cpuDelta) / float64(systemDelta) * 100
|
|
||||||
|
// Avoid division by zero and handle first run case
|
||||||
|
if systemDelta == 0 || prevCpuContainer == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return float64(cpuDelta) / float64(systemDelta) * 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185
|
// from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185
|
||||||
@@ -99,12 +105,14 @@ type prevNetStats struct {
|
|||||||
|
|
||||||
// Docker container stats
|
// Docker container stats
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Name string `json:"n"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
Cpu float64 `json:"c"`
|
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
||||||
Mem float64 `json:"m"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
NetworkSent float64 `json:"ns"`
|
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
||||||
NetworkRecv float64 `json:"nr"`
|
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
|
||||||
PrevCpu [2]uint64 `json:"-"`
|
// PrevCpu [2]uint64 `json:"-"`
|
||||||
PrevNet prevNetStats `json:"-"`
|
CpuSystem uint64 `json:"-"`
|
||||||
PrevRead time.Time `json:"-"`
|
CpuContainer uint64 `json:"-"`
|
||||||
|
PrevNet prevNetStats `json:"-"`
|
||||||
|
PrevReadTime time.Time `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,38 +8,47 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||||
MaxCpu float64 `json:"cpum,omitempty"`
|
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
Mem float64 `json:"m"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
MemUsed float64 `json:"mu"`
|
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
|
||||||
MemPct float64 `json:"mp"`
|
MemPct float64 `json:"mp" cbor:"4,keyasint"`
|
||||||
MemBuffCache float64 `json:"mb"`
|
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
|
||||||
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
|
||||||
Swap float64 `json:"s,omitempty"`
|
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
|
||||||
SwapUsed float64 `json:"su,omitempty"`
|
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
|
||||||
DiskTotal float64 `json:"d"`
|
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
|
||||||
DiskUsed float64 `json:"du"`
|
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
|
||||||
DiskReadPs float64 `json:"dr"`
|
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
|
||||||
DiskWritePs float64 `json:"dw"`
|
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
|
||||||
MaxDiskReadPs float64 `json:"drm,omitempty"`
|
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
|
||||||
MaxDiskWritePs float64 `json:"dwm,omitempty"`
|
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
NetworkSent float64 `json:"ns"`
|
NetworkSent float64 `json:"ns" cbor:"16,keyasint"`
|
||||||
NetworkRecv float64 `json:"nr"`
|
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
|
||||||
MaxNetworkSent float64 `json:"nsm,omitempty"`
|
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
|
||||||
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
|
||||||
Temperatures map[string]float64 `json:"t,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
GPUData map[string]GPUData `json:"g,omitempty"`
|
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||||
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||||
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
|
||||||
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
||||||
|
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
|
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||||
|
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
Name string `json:"n"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
Temperature float64 `json:"-"`
|
Temperature float64 `json:"-"`
|
||||||
MemoryUsed float64 `json:"mu,omitempty"`
|
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
MemoryTotal float64 `json:"mt,omitempty"`
|
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
|
||||||
Usage float64 `json:"u"`
|
Usage float64 `json:"u" cbor:"3,keyasint"`
|
||||||
Power float64 `json:"p,omitempty"`
|
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
Count float64 `json:"-"`
|
Count float64 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +56,14 @@ type FsStats struct {
|
|||||||
Time time.Time `json:"-"`
|
Time time.Time `json:"-"`
|
||||||
Root bool `json:"-"`
|
Root bool `json:"-"`
|
||||||
Mountpoint string `json:"-"`
|
Mountpoint string `json:"-"`
|
||||||
DiskTotal float64 `json:"d"`
|
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
||||||
DiskUsed float64 `json:"du"`
|
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
||||||
TotalRead uint64 `json:"-"`
|
TotalRead uint64 `json:"-"`
|
||||||
TotalWrite uint64 `json:"-"`
|
TotalWrite uint64 `json:"-"`
|
||||||
DiskReadPs float64 `json:"r"`
|
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
||||||
DiskWritePs float64 `json:"w"`
|
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||||
MaxDiskReadPS float64 `json:"rm,omitempty"`
|
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
MaxDiskWritePS float64 `json:"wm,omitempty"`
|
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetIoStats struct {
|
type NetIoStats struct {
|
||||||
@@ -64,7 +73,7 @@ type NetIoStats struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Os uint8
|
type Os = uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Linux Os = iota
|
Linux Os = iota
|
||||||
@@ -74,26 +83,32 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h"`
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
KernelVersion string `json:"k,omitempty"`
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
Cores int `json:"c"`
|
Cores int `json:"c" cbor:"2,keyasint"`
|
||||||
Threads int `json:"t,omitempty"`
|
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
CpuModel string `json:"m"`
|
CpuModel string `json:"m" cbor:"4,keyasint"`
|
||||||
Uptime uint64 `json:"u"`
|
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||||
MemPct float64 `json:"mp"`
|
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||||
Bandwidth float64 `json:"b"`
|
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
||||||
AgentVersion string `json:"v"`
|
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
||||||
Podman bool `json:"p,omitempty"`
|
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
||||||
GpuPct float64 `json:"g,omitempty"`
|
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||||
DashboardTemp float64 `json:"dt,omitempty"`
|
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||||
Os Os `json:"os"`
|
Os Os `json:"os" cbor:"14,keyasint"`
|
||||||
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
type CombinedData struct {
|
type CombinedData struct {
|
||||||
Stats Stats `json:"stats"`
|
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||||
Info Info `json:"info"`
|
Info Info `json:"info" cbor:"1,keyasint"`
|
||||||
Containers []*container.Stats `json:"container"`
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
}
|
}
|
||||||
|
|||||||
140
beszel/internal/ghupdate/extract.go
Normal file
140
beszel/internal/ghupdate/extract.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extract extracts an archive file to the destination directory.
|
||||||
|
// Supports .zip and .tar.gz files based on the file extension.
|
||||||
|
func extract(srcPath, destDir string) error {
|
||||||
|
if strings.HasSuffix(srcPath, ".tar.gz") {
|
||||||
|
return extractTarGz(srcPath, destDir)
|
||||||
|
}
|
||||||
|
// Default to zip extraction
|
||||||
|
return extractZip(srcPath, destDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTarGz extracts a tar.gz archive to the destination directory.
|
||||||
|
func extractTarGz(srcPath, destDir string) error {
|
||||||
|
src, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Typeflag == tar.TypeDir {
|
||||||
|
if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile, err := os.Create(filepath.Join(destDir, header.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(outFile, tr); err != nil {
|
||||||
|
outFile.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
outFile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractZip extracts the zip archive at "src" to "dest".
|
||||||
|
//
|
||||||
|
// Note that only dirs and regular files will be extracted.
|
||||||
|
// Symbolic links, named pipes, sockets, or any other irregular files
|
||||||
|
// are skipped because they come with too many edge cases and ambiguities.
|
||||||
|
func extractZip(src, dest string) error {
|
||||||
|
zr, err := zip.OpenReader(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
// normalize dest path to check later for Zip Slip
|
||||||
|
dest = filepath.Clean(dest) + string(os.PathSeparator)
|
||||||
|
|
||||||
|
for _, f := range zr.File {
|
||||||
|
err := extractFile(f, dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFile extracts the provided zipFile into "basePath/zipFileName" path,
|
||||||
|
// creating all the necessary path directories.
|
||||||
|
func extractFile(zipFile *zip.File, basePath string) error {
|
||||||
|
path := filepath.Join(basePath, zipFile.Name)
|
||||||
|
|
||||||
|
// check for Zip Slip
|
||||||
|
if !strings.HasPrefix(path, basePath) {
|
||||||
|
return fmt.Errorf("invalid file path: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := zipFile.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
// allow only dirs or regular files
|
||||||
|
if zipFile.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(path, os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if zipFile.FileInfo().Mode().IsRegular() {
|
||||||
|
// ensure that the file path directories are created
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(f, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
348
beszel/internal/ghupdate/ghupdate.go
Normal file
348
beszel/internal/ghupdate/ghupdate.go
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
// Package ghupdate implements a new command to self update the current
|
||||||
|
// executable with the latest GitHub release. This is based on PocketBase's
|
||||||
|
// ghupdate package with modifications.
|
||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Minimal color functions using ANSI escape codes
|
||||||
|
const (
|
||||||
|
colorReset = "\033[0m"
|
||||||
|
ColorYellow = "\033[33m"
|
||||||
|
ColorGreen = "\033[32m"
|
||||||
|
colorCyan = "\033[36m"
|
||||||
|
colorGray = "\033[90m"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ColorPrint(color, text string) {
|
||||||
|
fmt.Println(color + text + colorReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ColorPrintf(color, format string, args ...interface{}) {
|
||||||
|
fmt.Printf(color+format+colorReset+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HttpClient is a base HTTP client interface (usually used for test purposes).
|
||||||
|
type HttpClient interface {
|
||||||
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config defines the config options of the ghupdate plugin.
|
||||||
|
//
|
||||||
|
// NB! This plugin is considered experimental and its config options may change in the future.
|
||||||
|
type Config struct {
|
||||||
|
// Owner specifies the account owner of the repository (default to "pocketbase").
|
||||||
|
Owner string
|
||||||
|
|
||||||
|
// Repo specifies the name of the repository (default to "pocketbase").
|
||||||
|
Repo string
|
||||||
|
|
||||||
|
// ArchiveExecutable specifies the name of the executable file in the release archive
|
||||||
|
// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
|
||||||
|
ArchiveExecutable string
|
||||||
|
|
||||||
|
// Optional context to use when fetching and downloading the latest release.
|
||||||
|
Context context.Context
|
||||||
|
|
||||||
|
// The HTTP client to use when fetching and downloading the latest release.
|
||||||
|
// Defaults to `http.DefaultClient`.
|
||||||
|
HttpClient HttpClient
|
||||||
|
|
||||||
|
// The data directory to use when fetching and downloading the latest release.
|
||||||
|
DataDir string
|
||||||
|
|
||||||
|
// UseMirror specifies whether to use the beszel.dev mirror instead of GitHub API.
|
||||||
|
// When false (default), always uses api.github.com. When true, uses gh.beszel.dev.
|
||||||
|
UseMirror bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type updater struct {
|
||||||
|
config Config
|
||||||
|
currentVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Update(config Config) (updated bool, err error) {
|
||||||
|
p := &updater{
|
||||||
|
currentVersion: beszel.Version,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *updater) update() (updated bool, err error) {
|
||||||
|
ColorPrint(ColorYellow, "Fetching release information...")
|
||||||
|
|
||||||
|
if p.config.DataDir == "" {
|
||||||
|
p.config.DataDir = os.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.config.Owner == "" {
|
||||||
|
p.config.Owner = "henrygd"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.config.Repo == "" {
|
||||||
|
p.config.Repo = "beszel"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.config.Context == nil {
|
||||||
|
p.config.Context = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.config.HttpClient == nil {
|
||||||
|
p.config.HttpClient = http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
var latest *release
|
||||||
|
var useMirror bool
|
||||||
|
|
||||||
|
// Determine the API endpoint based on UseMirror flag
|
||||||
|
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo)
|
||||||
|
if p.config.UseMirror {
|
||||||
|
useMirror = true
|
||||||
|
apiURL = fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo)
|
||||||
|
ColorPrint(ColorYellow, "Using mirror for update.")
|
||||||
|
}
|
||||||
|
|
||||||
|
latest, err = fetchLatestRelease(
|
||||||
|
p.config.Context,
|
||||||
|
p.config.HttpClient,
|
||||||
|
apiURL,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVersion := semver.MustParse(strings.TrimPrefix(p.currentVersion, "v"))
|
||||||
|
newVersion := semver.MustParse(strings.TrimPrefix(latest.Tag, "v"))
|
||||||
|
|
||||||
|
if newVersion.LTE(currentVersion) {
|
||||||
|
ColorPrintf(ColorGreen, "You already have the latest version %s.", p.currentVersion)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix := archiveSuffix(p.config.ArchiveExecutable, runtime.GOOS, runtime.GOARCH)
|
||||||
|
asset, err := latest.findAssetBySuffix(suffix)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDir := filepath.Join(p.config.DataDir, ".beszel_update")
|
||||||
|
defer os.RemoveAll(releaseDir)
|
||||||
|
|
||||||
|
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
|
||||||
|
|
||||||
|
// download the release asset
|
||||||
|
assetPath := filepath.Join(releaseDir, asset.Name)
|
||||||
|
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, useMirror); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
|
||||||
|
|
||||||
|
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
|
||||||
|
defer os.RemoveAll(extractDir)
|
||||||
|
|
||||||
|
// Extract the archive (automatically detects format)
|
||||||
|
if err := extract(assetPath, extractDir); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorPrint(ColorYellow, "Replacing the executable...")
|
||||||
|
|
||||||
|
oldExec, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
renamedOldExec := oldExec + ".old"
|
||||||
|
defer os.Remove(renamedOldExec)
|
||||||
|
|
||||||
|
newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
|
||||||
|
if _, err := os.Stat(newExec); err != nil {
|
||||||
|
// try again with an .exe extension
|
||||||
|
newExec = newExec + ".exe"
|
||||||
|
if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
|
||||||
|
return false, fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rename the current executable
|
||||||
|
if err := os.Rename(oldExec, renamedOldExec); err != nil {
|
||||||
|
return false, fmt.Errorf("failed to rename the current executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tryToRevertExecChanges := func() {
|
||||||
|
if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
|
||||||
|
slog.Debug(
|
||||||
|
"Failed to revert executable",
|
||||||
|
slog.String("old", renamedOldExec),
|
||||||
|
slog.String("new", oldExec),
|
||||||
|
slog.String("error", revertErr.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace with the extracted binary
|
||||||
|
if err := os.Rename(newExec, oldExec); err != nil {
|
||||||
|
// If rename fails due to cross-device link, try copying instead
|
||||||
|
if isCrossDeviceError(err) {
|
||||||
|
if err := copyFile(newExec, oldExec); err != nil {
|
||||||
|
tryToRevertExecChanges()
|
||||||
|
return false, fmt.Errorf("failed replacing the executable: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tryToRevertExecChanges()
|
||||||
|
return false, fmt.Errorf("failed replacing the executable: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorPrint(colorGray, "---")
|
||||||
|
ColorPrint(ColorGreen, "Update completed successfully!")
|
||||||
|
|
||||||
|
// print the release notes
|
||||||
|
if latest.Body != "" {
|
||||||
|
fmt.Print("\n")
|
||||||
|
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
|
||||||
|
ColorPrint(colorCyan, releaseNotes)
|
||||||
|
fmt.Print("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatestRelease(
|
||||||
|
ctx context.Context,
|
||||||
|
client HttpClient,
|
||||||
|
url string,
|
||||||
|
) (*release, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
rawBody, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// http.Client doesn't treat non 2xx responses as error
|
||||||
|
if res.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"(%d) failed to fetch latest releases:\n%s",
|
||||||
|
res.StatusCode,
|
||||||
|
string(rawBody),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &release{}
|
||||||
|
if err := json.Unmarshal(rawBody, result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(
|
||||||
|
ctx context.Context,
|
||||||
|
client HttpClient,
|
||||||
|
url string,
|
||||||
|
destPath string,
|
||||||
|
useMirror bool,
|
||||||
|
) error {
|
||||||
|
if useMirror {
|
||||||
|
url = strings.Replace(url, "github.com", "gh.beszel.dev", 1)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
// http.Client doesn't treat non 2xx responses as error
|
||||||
|
if res.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that the dest parent dir(s) exist
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dest, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dest.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dest, res.Body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCrossDeviceError checks if the error is due to a cross-device link
|
||||||
|
func isCrossDeviceError(err error) bool {
|
||||||
|
return err != nil && (strings.Contains(err.Error(), "cross-device") ||
|
||||||
|
strings.Contains(err.Error(), "EXDEV"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile copies a file from src to dst, preserving permissions
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
sourceFile, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sourceFile.Close()
|
||||||
|
|
||||||
|
destFile, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
// Copy the file contents
|
||||||
|
if _, err := io.Copy(destFile, sourceFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the original file permissions
|
||||||
|
sourceInfo, err := sourceFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return destFile.Chmod(sourceInfo.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveSuffix(binaryName, goos, goarch string) string {
|
||||||
|
if goos == "windows" {
|
||||||
|
return fmt.Sprintf("%s_%s_%s.zip", binaryName, goos, goarch)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch)
|
||||||
|
}
|
||||||
45
beszel/internal/ghupdate/ghupdate_test.go
Normal file
45
beszel/internal/ghupdate/ghupdate_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReleaseFindAssetBySuffix(t *testing.T) {
|
||||||
|
r := release{
|
||||||
|
Assets: []*releaseAsset{
|
||||||
|
{Name: "test1.zip", Id: 1},
|
||||||
|
{Name: "test2.zip", Id: 2},
|
||||||
|
{Name: "test22.zip", Id: 22},
|
||||||
|
{Name: "test3.zip", Id: 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
asset, err := r.findAssetBySuffix("2.zip")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected nil, got err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asset.Id != 2 {
|
||||||
|
t.Fatalf("Expected asset with id %d, got %v", 2, asset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractFailure(t *testing.T) {
|
||||||
|
testDir := t.TempDir()
|
||||||
|
|
||||||
|
// Test with missing zip file
|
||||||
|
missingZipPath := filepath.Join(testDir, "missing_test.zip")
|
||||||
|
extractedPath := filepath.Join(testDir, "zip_extract")
|
||||||
|
|
||||||
|
if err := extract(missingZipPath, extractedPath); err == nil {
|
||||||
|
t.Fatal("Expected Extract to fail due to missing zip file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with missing tar.gz file
|
||||||
|
missingTarPath := filepath.Join(testDir, "missing_test.tar.gz")
|
||||||
|
|
||||||
|
if err := extract(missingTarPath, extractedPath); err == nil {
|
||||||
|
t.Fatal("Expected Extract to fail due to missing tar.gz file")
|
||||||
|
}
|
||||||
|
}
|
||||||
36
beszel/internal/ghupdate/release.go
Normal file
36
beszel/internal/ghupdate/release.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type releaseAsset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DownloadUrl string `json:"browser_download_url"`
|
||||||
|
Id int `json:"id"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type release struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tag string `json:"tag_name"`
|
||||||
|
Published string `json:"published_at"`
|
||||||
|
Url string `json:"html_url"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Assets []*releaseAsset `json:"assets"`
|
||||||
|
Id int `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAssetBySuffix returns the first available asset containing the specified suffix.
|
||||||
|
func (r *release) findAssetBySuffix(suffix string) (*releaseAsset, error) {
|
||||||
|
if suffix != "" {
|
||||||
|
for _, asset := range r.Assets {
|
||||||
|
if strings.HasSuffix(asset.Name, suffix) {
|
||||||
|
return asset, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("missing asset containing " + suffix)
|
||||||
|
}
|
||||||
320
beszel/internal/hub/agent_connect.go
Normal file
320
beszel/internal/hub/agent_connect.go
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/hub/expirymap"
|
||||||
|
"beszel/internal/hub/ws"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentConnectRequest holds information related to an agent's connection attempt.
|
||||||
|
type agentConnectRequest struct {
|
||||||
|
hub *Hub
|
||||||
|
req *http.Request
|
||||||
|
res http.ResponseWriter
|
||||||
|
token string
|
||||||
|
agentSemVer semver.Version
|
||||||
|
// isUniversalToken is true if the token is a universal token.
|
||||||
|
isUniversalToken bool
|
||||||
|
// userId is the user ID associated with the universal token.
|
||||||
|
userId string
|
||||||
|
}
|
||||||
|
|
||||||
|
// universalTokenMap stores active universal tokens and their associated user IDs.
|
||||||
|
var universalTokenMap tokenMap
|
||||||
|
|
||||||
|
type tokenMap struct {
|
||||||
|
store *expirymap.ExpiryMap[string]
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMap returns the expirymap, creating it if necessary.
|
||||||
|
func (tm *tokenMap) GetMap() *expirymap.ExpiryMap[string] {
|
||||||
|
tm.once.Do(func() {
|
||||||
|
tm.store = expirymap.New[string](time.Hour)
|
||||||
|
})
|
||||||
|
return tm.store
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAgentConnect is the HTTP handler for an agent's connection request.
|
||||||
|
func (h *Hub) handleAgentConnect(e *core.RequestEvent) error {
|
||||||
|
agentRequest := agentConnectRequest{req: e.Request, res: e.Response, hub: h}
|
||||||
|
_ = agentRequest.agentConnect()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentConnect validates agent credentials and upgrades the connection to a WebSocket.
|
||||||
|
func (acr *agentConnectRequest) agentConnect() (err error) {
|
||||||
|
var agentVersion string
|
||||||
|
|
||||||
|
acr.token, agentVersion, err = acr.validateAgentHeaders(acr.req.Header)
|
||||||
|
if err != nil {
|
||||||
|
return acr.sendResponseError(acr.res, http.StatusBadRequest, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is an active universal token
|
||||||
|
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
|
||||||
|
|
||||||
|
// Find matching fingerprint records for this token
|
||||||
|
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
|
||||||
|
if len(fpRecords) == 0 && !acr.isUniversalToken {
|
||||||
|
// Invalid token - no records found and not a universal token
|
||||||
|
return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate agent version
|
||||||
|
acr.agentSemVer, err = semver.Parse(agentVersion)
|
||||||
|
if err != nil {
|
||||||
|
return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid agent version")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade connection to WebSocket
|
||||||
|
conn, err := ws.GetUpgrader().Upgrade(acr.res, acr.req)
|
||||||
|
if err != nil {
|
||||||
|
return acr.sendResponseError(acr.res, http.StatusInternalServerError, "WebSocket upgrade failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
go acr.verifyWsConn(conn, fpRecords)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
|
||||||
|
// SSH key signature, then adds the system to the system manager.
|
||||||
|
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
|
||||||
|
wsConn := ws.NewWsConnection(conn)
|
||||||
|
|
||||||
|
// must set wsConn in connection store before the read loop
|
||||||
|
conn.Session().Store("wsConn", wsConn)
|
||||||
|
|
||||||
|
// make sure connection is closed if there is an error
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
wsConn.Close([]byte(err.Error()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go conn.ReadLoop()
|
||||||
|
|
||||||
|
signer, err := acr.hub.GetSSHKey("")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
agentFingerprint, err := wsConn.GetFingerprint(acr.token, signer, acr.isUniversalToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create the appropriate system for this token and fingerprint
|
||||||
|
fpRecord, err := acr.findOrCreateSystemForToken(fpRecords, agentFingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return acr.hub.sm.AddWebSocketSystem(fpRecord.SystemId, acr.agentSemVer, wsConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAgentHeaders extracts and validates the token and agent version from HTTP headers.
|
||||||
|
func (acr *agentConnectRequest) validateAgentHeaders(headers http.Header) (string, string, error) {
|
||||||
|
token := headers.Get("X-Token")
|
||||||
|
agentVersion := headers.Get("X-Beszel")
|
||||||
|
|
||||||
|
if agentVersion == "" || token == "" || len(token) > 64 {
|
||||||
|
return "", "", errors.New("")
|
||||||
|
}
|
||||||
|
return token, agentVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendResponseError writes an HTTP error response.
|
||||||
|
func (acr *agentConnectRequest) sendResponseError(res http.ResponseWriter, code int, message string) error {
|
||||||
|
res.WriteHeader(code)
|
||||||
|
if message != "" {
|
||||||
|
res.Write([]byte(message))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFingerprintRecordsByToken retrieves all fingerprint records associated with a given token.
|
||||||
|
func getFingerprintRecordsByToken(token string, h *Hub) []ws.FingerprintRecord {
|
||||||
|
var records []ws.FingerprintRecord
|
||||||
|
// All will populate empty slice even on error
|
||||||
|
_ = h.DB().NewQuery("SELECT id, system, fingerprint, token FROM fingerprints WHERE token = {:token}").
|
||||||
|
Bind(dbx.Params{
|
||||||
|
"token": token,
|
||||||
|
}).
|
||||||
|
All(&records)
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOrCreateSystemForToken finds an existing system matching the token and fingerprint,
|
||||||
|
// or creates a new one for a universal token.
|
||||||
|
func (acr *agentConnectRequest) findOrCreateSystemForToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||||
|
// No records - only valid for active universal tokens
|
||||||
|
if len(fpRecords) == 0 {
|
||||||
|
return acr.handleNoRecords(agentFingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single record - handle as regular token
|
||||||
|
if len(fpRecords) == 1 && !acr.isUniversalToken {
|
||||||
|
return acr.handleSingleRecord(fpRecords[0], agentFingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple records or universal token - look for matching fingerprint
|
||||||
|
return acr.handleMultipleRecordsOrUniversalToken(fpRecords, agentFingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNoRecords handles the case where no fingerprint records are found for a token.
|
||||||
|
// A new system is created if the token is a valid universal token.
|
||||||
|
func (acr *agentConnectRequest) handleNoRecords(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||||
|
var fpRecord ws.FingerprintRecord
|
||||||
|
|
||||||
|
if !acr.isUniversalToken || acr.userId == "" {
|
||||||
|
return fpRecord, errors.New("no matching fingerprints")
|
||||||
|
}
|
||||||
|
|
||||||
|
return acr.createNewSystemForUniversalToken(agentFingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSingleRecord handles the case with a single fingerprint record. It validates
|
||||||
|
// the agent's fingerprint against the stored one, or sets it on first connect.
|
||||||
|
func (acr *agentConnectRequest) handleSingleRecord(fpRecord ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||||
|
// If no current fingerprint, update with new fingerprint (first time connecting)
|
||||||
|
if fpRecord.Fingerprint == "" {
|
||||||
|
if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
|
||||||
|
return fpRecord, err
|
||||||
|
}
|
||||||
|
// Update the record with the fingerprint that was set
|
||||||
|
fpRecord.Fingerprint = agentFingerprint.Fingerprint
|
||||||
|
return fpRecord, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort if fingerprint exists but doesn't match (different machine)
|
||||||
|
if fpRecord.Fingerprint != agentFingerprint.Fingerprint {
|
||||||
|
return fpRecord, errors.New("fingerprint mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fpRecord, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMultipleRecordsOrUniversalToken finds a matching fingerprint from multiple records.
|
||||||
|
// If no match is found and the token is a universal token, a new system is created.
|
||||||
|
func (acr *agentConnectRequest) handleMultipleRecordsOrUniversalToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||||
|
// Return existing record with matching fingerprint if found
|
||||||
|
for i := range fpRecords {
|
||||||
|
if fpRecords[i].Fingerprint == agentFingerprint.Fingerprint {
|
||||||
|
return fpRecords[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matching fingerprint record found, but it's
|
||||||
|
// an active universal token so create a new system
|
||||||
|
if acr.isUniversalToken {
|
||||||
|
return acr.createNewSystemForUniversalToken(agentFingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ws.FingerprintRecord{}, errors.New("fingerprint mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// createNewSystemForUniversalToken creates a new system and fingerprint record for a universal token.
|
||||||
|
func (acr *agentConnectRequest) createNewSystemForUniversalToken(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
|
||||||
|
var fpRecord ws.FingerprintRecord
|
||||||
|
if !acr.isUniversalToken || acr.userId == "" {
|
||||||
|
return fpRecord, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
fpRecord.Token = acr.token
|
||||||
|
|
||||||
|
systemId, err := acr.createSystem(agentFingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return fpRecord, err
|
||||||
|
}
|
||||||
|
fpRecord.SystemId = systemId
|
||||||
|
|
||||||
|
// Set the fingerprint for the new system
|
||||||
|
if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
|
||||||
|
return fpRecord, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the record with the fingerprint that was set
|
||||||
|
fpRecord.Fingerprint = agentFingerprint.Fingerprint
|
||||||
|
|
||||||
|
return fpRecord, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSystem creates a new system record in the database using details from the agent.
|
||||||
|
func (acr *agentConnectRequest) createSystem(agentFingerprint common.FingerprintResponse) (recordId string, err error) {
|
||||||
|
systemsCollection, err := acr.hub.FindCachedCollectionByNameOrId("systems")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
remoteAddr := getRealIP(acr.req)
|
||||||
|
// separate port from address
|
||||||
|
if agentFingerprint.Hostname == "" {
|
||||||
|
agentFingerprint.Hostname = remoteAddr
|
||||||
|
}
|
||||||
|
if agentFingerprint.Port == "" {
|
||||||
|
agentFingerprint.Port = "45876"
|
||||||
|
}
|
||||||
|
// create new record
|
||||||
|
systemRecord := core.NewRecord(systemsCollection)
|
||||||
|
systemRecord.Set("name", agentFingerprint.Hostname)
|
||||||
|
systemRecord.Set("host", remoteAddr)
|
||||||
|
systemRecord.Set("port", agentFingerprint.Port)
|
||||||
|
systemRecord.Set("users", []string{acr.userId})
|
||||||
|
|
||||||
|
return systemRecord.Id, acr.hub.Save(systemRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFingerprint creates or updates a fingerprint record in the database.
|
||||||
|
func (h *Hub) SetFingerprint(fpRecord *ws.FingerprintRecord, fingerprint string) (err error) {
|
||||||
|
// // can't use raw query here because it doesn't trigger SSE
|
||||||
|
var record *core.Record
|
||||||
|
switch fpRecord.Id {
|
||||||
|
case "":
|
||||||
|
// create new record for universal token
|
||||||
|
collection, _ := h.FindCachedCollectionByNameOrId("fingerprints")
|
||||||
|
record = core.NewRecord(collection)
|
||||||
|
record.Set("system", fpRecord.SystemId)
|
||||||
|
default:
|
||||||
|
record, err = h.FindRecordById("fingerprints", fpRecord.Id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
record.Set("token", fpRecord.Token)
|
||||||
|
record.Set("fingerprint", fingerprint)
|
||||||
|
return h.SaveNoValidate(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRealIP extracts the client's real IP address from request headers,
|
||||||
|
// checking common proxy headers before falling back to the remote address.
|
||||||
|
func getRealIP(r *http.Request) string {
|
||||||
|
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||||
|
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
|
||||||
|
// Take the first one
|
||||||
|
ips := strings.Split(ip, ",")
|
||||||
|
if len(ips) > 0 {
|
||||||
|
return strings.TrimSpace(ips[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to RemoteAddr
|
||||||
|
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
1700
beszel/internal/hub/agent_connect_test.go
Normal file
1700
beszel/internal/hub/agent_connect_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
package hub
|
// Package config provides functions for syncing systems with the config.yml file
|
||||||
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
@@ -7,26 +8,27 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type config struct {
|
||||||
Systems []SystemConfig `yaml:"systems"`
|
Systems []systemConfig `yaml:"systems"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemConfig struct {
|
type systemConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port uint16 `yaml:"port,omitempty"`
|
Port uint16 `yaml:"port,omitempty"`
|
||||||
|
Token string `yaml:"token,omitempty"`
|
||||||
Users []string `yaml:"users"`
|
Users []string `yaml:"users"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Syncs systems with the config.yml file
|
// Syncs systems with the config.yml file
|
||||||
func syncSystemsWithConfig(e *core.ServeEvent) error {
|
func SyncSystems(e *core.ServeEvent) error {
|
||||||
h := e.App
|
h := e.App
|
||||||
configPath := filepath.Join(h.DataDir(), "config.yml")
|
configPath := filepath.Join(h.DataDir(), "config.yml")
|
||||||
configData, err := os.ReadFile(configPath)
|
configData, err := os.ReadFile(configPath)
|
||||||
@@ -34,7 +36,7 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var config Config
|
var config config
|
||||||
err = yaml.Unmarshal(configData, &config)
|
err = yaml.Unmarshal(configData, &config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse config.yml: %v", err)
|
return fmt.Errorf("failed to parse config.yml: %v", err)
|
||||||
@@ -107,6 +109,14 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
|
|||||||
if err := h.Save(existingSystem); err != nil {
|
if err := h.Save(existingSystem); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only update token if one is specified in config, otherwise preserve existing token
|
||||||
|
if sysConfig.Token != "" {
|
||||||
|
if err := updateFingerprintToken(h, existingSystem.Id, sysConfig.Token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
delete(existingSystemsMap, key)
|
delete(existingSystemsMap, key)
|
||||||
} else {
|
} else {
|
||||||
// Create new system
|
// Create new system
|
||||||
@@ -124,10 +134,21 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
|
|||||||
if err := h.Save(newSystem); err != nil {
|
if err := h.Save(newSystem); err != nil {
|
||||||
return fmt.Errorf("failed to create new system: %v", err)
|
return fmt.Errorf("failed to create new system: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For new systems, generate token if not provided
|
||||||
|
token := sysConfig.Token
|
||||||
|
if token == "" {
|
||||||
|
token = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fingerprint record for new system
|
||||||
|
if err := createFingerprintRecord(h, newSystem.Id, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete systems not in config
|
// Delete systems not in config (and their fingerprint records will cascade delete)
|
||||||
for _, system := range existingSystemsMap {
|
for _, system := range existingSystemsMap {
|
||||||
if err := h.Delete(system); err != nil {
|
if err := h.Delete(system); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -139,7 +160,7 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generates content for the config.yml file as a YAML string
|
// Generates content for the config.yml file as a YAML string
|
||||||
func (h *Hub) generateConfigYAML() (string, error) {
|
func generateYAML(h core.App) (string, error) {
|
||||||
// Fetch all systems from the database
|
// Fetch all systems from the database
|
||||||
systems, err := h.FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
|
systems, err := h.FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,8 +168,8 @@ func (h *Hub) generateConfigYAML() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a Config struct to hold the data
|
// Create a Config struct to hold the data
|
||||||
config := Config{
|
config := config{
|
||||||
Systems: make([]SystemConfig, 0, len(systems)),
|
Systems: make([]systemConfig, 0, len(systems)),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all users at once
|
// Fetch all users at once
|
||||||
@@ -156,11 +177,29 @@ func (h *Hub) generateConfigYAML() (string, error) {
|
|||||||
for _, system := range systems {
|
for _, system := range systems {
|
||||||
allUserIDs = append(allUserIDs, system.GetStringSlice("users")...)
|
allUserIDs = append(allUserIDs, system.GetStringSlice("users")...)
|
||||||
}
|
}
|
||||||
userEmailMap, err := h.getUserEmailMap(allUserIDs)
|
userEmailMap, err := getUserEmailMap(h, allUserIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch all fingerprint records to get tokens
|
||||||
|
type fingerprintData struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
System string `db:"system"`
|
||||||
|
Token string `db:"token"`
|
||||||
|
}
|
||||||
|
var fingerprints []fingerprintData
|
||||||
|
err = h.DB().NewQuery("SELECT id, system, token FROM fingerprints").All(&fingerprints)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of system ID to token
|
||||||
|
systemTokenMap := make(map[string]string)
|
||||||
|
for _, fingerprint := range fingerprints {
|
||||||
|
systemTokenMap[fingerprint.System] = fingerprint.Token
|
||||||
|
}
|
||||||
|
|
||||||
// Populate the Config struct with system data
|
// Populate the Config struct with system data
|
||||||
for _, system := range systems {
|
for _, system := range systems {
|
||||||
userIDs := system.GetStringSlice("users")
|
userIDs := system.GetStringSlice("users")
|
||||||
@@ -171,11 +210,12 @@ func (h *Hub) generateConfigYAML() (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sysConfig := SystemConfig{
|
sysConfig := systemConfig{
|
||||||
Name: system.GetString("name"),
|
Name: system.GetString("name"),
|
||||||
Host: system.GetString("host"),
|
Host: system.GetString("host"),
|
||||||
Port: cast.ToUint16(system.Get("port")),
|
Port: cast.ToUint16(system.Get("port")),
|
||||||
Users: userEmails,
|
Users: userEmails,
|
||||||
|
Token: systemTokenMap[system.Id],
|
||||||
}
|
}
|
||||||
config.Systems = append(config.Systems, sysConfig)
|
config.Systems = append(config.Systems, sysConfig)
|
||||||
}
|
}
|
||||||
@@ -187,13 +227,13 @@ func (h *Hub) generateConfigYAML() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add a header to the YAML
|
// Add a header to the YAML
|
||||||
yamlData = append([]byte("# Values for port and users are optional.\n# Defaults are port 45876 and the first created user.\n\n"), yamlData...)
|
yamlData = append([]byte("# Values for port, users, and token are optional.\n# Defaults are port 45876, the first created user, and a generated UUID token.\n\n"), yamlData...)
|
||||||
|
|
||||||
return string(yamlData), nil
|
return string(yamlData), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// New helper function to get a map of user IDs to emails
|
// New helper function to get a map of user IDs to emails
|
||||||
func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
|
func getUserEmailMap(h core.App, userIDs []string) (map[string]string, error) {
|
||||||
users, err := h.FindRecordsByIds("users", userIDs)
|
users, err := h.FindRecordsByIds("users", userIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -207,13 +247,41 @@ func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
|
|||||||
return userEmailMap, nil
|
return userEmailMap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the current config.yml file as a JSON object
|
// Helper function to update or create fingerprint token for an existing system
|
||||||
func (h *Hub) getYamlConfig(e *core.RequestEvent) error {
|
func updateFingerprintToken(app core.App, systemID, token string) error {
|
||||||
info, _ := e.RequestInfo()
|
// Try to find existing fingerprint record
|
||||||
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
|
fingerprint, err := app.FindFirstRecordByFilter("fingerprints", "system = {:system}", dbx.Params{"system": systemID})
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
if err != nil {
|
||||||
|
// If no fingerprint record exists, create one
|
||||||
|
return createFingerprintRecord(app, systemID, token)
|
||||||
}
|
}
|
||||||
configContent, err := h.generateConfigYAML()
|
|
||||||
|
// Update existing fingerprint record with new token (keep existing fingerprint)
|
||||||
|
fingerprint.Set("token", token)
|
||||||
|
return app.Save(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create a new fingerprint record for a system
|
||||||
|
func createFingerprintRecord(app core.App, systemID, token string) error {
|
||||||
|
fingerprintsCollection, err := app.FindCollectionByNameOrId("fingerprints")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find fingerprints collection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newFingerprint := core.NewRecord(fingerprintsCollection)
|
||||||
|
newFingerprint.Set("system", systemID)
|
||||||
|
newFingerprint.Set("token", token)
|
||||||
|
newFingerprint.Set("fingerprint", "") // Empty fingerprint, will be set on first connection
|
||||||
|
|
||||||
|
return app.Save(newFingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the current config.yml file as a JSON object
|
||||||
|
func GetYamlConfig(e *core.RequestEvent) error {
|
||||||
|
if e.Auth.GetString("role") != "admin" {
|
||||||
|
return e.ForbiddenError("Requires admin role", nil)
|
||||||
|
}
|
||||||
|
configContent, err := generateYAML(e.App)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
245
beszel/internal/hub/config/config_test.go
Normal file
245
beszel/internal/hub/config/config_test.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/hub/config"
|
||||||
|
"beszel/internal/tests"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config struct for testing (copied from config package since it's not exported)
|
||||||
|
type testConfig struct {
|
||||||
|
Systems []testSystemConfig `yaml:"systems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type testSystemConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port uint16 `yaml:"port,omitempty"`
|
||||||
|
Users []string `yaml:"users"`
|
||||||
|
Token string `yaml:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create a test system for config tests
|
||||||
|
// func createConfigTestSystem(app core.App, name, host string, port uint16, userIDs []string) (*core.Record, error) {
|
||||||
|
// systemCollection, err := app.FindCollectionByNameOrId("systems")
|
||||||
|
// if err != nil {
|
||||||
|
// return nil, err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// system := core.NewRecord(systemCollection)
|
||||||
|
// system.Set("name", name)
|
||||||
|
// system.Set("host", host)
|
||||||
|
// system.Set("port", port)
|
||||||
|
// system.Set("users", userIDs)
|
||||||
|
// system.Set("status", "pending")
|
||||||
|
|
||||||
|
// return system, app.Save(system)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Helper function to create a fingerprint record
|
||||||
|
func createConfigTestFingerprint(app core.App, systemID, token, fingerprint string) (*core.Record, error) {
|
||||||
|
fingerprintCollection, err := app.FindCollectionByNameOrId("fingerprints")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fp := core.NewRecord(fingerprintCollection)
|
||||||
|
fp.Set("system", systemID)
|
||||||
|
fp.Set("token", token)
|
||||||
|
fp.Set("fingerprint", fingerprint)
|
||||||
|
|
||||||
|
return fp, app.Save(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
|
||||||
|
func TestConfigSyncWithTokens(t *testing.T) {
|
||||||
|
testHub, err := tests.NewTestHub()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer testHub.Cleanup()
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
setupFunc func() (string, *core.Record, *core.Record) // Returns: existing token, system record, fingerprint record
|
||||||
|
configYAML string
|
||||||
|
expectToken string // Expected token after sync
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "new system with token in config",
|
||||||
|
setupFunc: func() (string, *core.Record, *core.Record) {
|
||||||
|
return "", nil, nil // No existing system
|
||||||
|
},
|
||||||
|
configYAML: `systems:
|
||||||
|
- name: "new-server"
|
||||||
|
host: "new.example.com"
|
||||||
|
port: 45876
|
||||||
|
users:
|
||||||
|
- "admin@example.com"
|
||||||
|
token: "explicit-token-123"`,
|
||||||
|
expectToken: "explicit-token-123",
|
||||||
|
description: "New system should use token from config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing system without token in config (preserve existing)",
|
||||||
|
setupFunc: func() (string, *core.Record, *core.Record) {
|
||||||
|
// Create existing system and fingerprint
|
||||||
|
system, err := tests.CreateRecord(testHub.App, "systems", map[string]any{
|
||||||
|
"name": "preserve-server",
|
||||||
|
"host": "preserve.example.com",
|
||||||
|
"port": 45876,
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fingerprint, err := createConfigTestFingerprint(testHub.App, system.Id, "preserve-token-999", "preserve-fingerprint")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return "preserve-token-999", system, fingerprint
|
||||||
|
},
|
||||||
|
configYAML: `systems:
|
||||||
|
- name: "preserve-server"
|
||||||
|
host: "preserve.example.com"
|
||||||
|
port: 45876
|
||||||
|
users:
|
||||||
|
- "admin@example.com"`,
|
||||||
|
expectToken: "preserve-token-999",
|
||||||
|
description: "Existing system should preserve original token when config doesn't specify one",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Setup test data
|
||||||
|
_, existingSystem, existingFingerprint := tc.setupFunc()
|
||||||
|
|
||||||
|
// Write config file
|
||||||
|
configPath := filepath.Join(testHub.DataDir(), "config.yml")
|
||||||
|
err := os.WriteFile(configPath, []byte(tc.configYAML), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create serve event and sync
|
||||||
|
event := &core.ServeEvent{App: testHub.App}
|
||||||
|
err = config.SyncSystems(event)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse the config to get the system name for verification
|
||||||
|
var configData testConfig
|
||||||
|
err = yaml.Unmarshal([]byte(tc.configYAML), &configData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, configData.Systems, 1)
|
||||||
|
systemName := configData.Systems[0].Name
|
||||||
|
|
||||||
|
// Find the system after sync
|
||||||
|
systems, err := testHub.FindRecordsByFilter("systems", "name = {:name}", "", -1, 0, map[string]any{"name": systemName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, systems, 1)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Find the fingerprint record
|
||||||
|
fingerprints, err := testHub.FindRecordsByFilter("fingerprints", "system = {:system}", "", -1, 0, map[string]any{"system": system.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, fingerprints, 1)
|
||||||
|
fingerprint := fingerprints[0]
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
actualToken := fingerprint.GetString("token")
|
||||||
|
if tc.expectToken == "" {
|
||||||
|
// For generated tokens, just verify it's not empty and is a valid UUID format
|
||||||
|
assert.NotEmpty(t, actualToken, tc.description)
|
||||||
|
assert.Len(t, actualToken, 36, "Generated token should be UUID format") // UUID length
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, tc.expectToken, actualToken, tc.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For existing systems, verify fingerprint is preserved
|
||||||
|
if existingFingerprint != nil {
|
||||||
|
actualFingerprint := fingerprint.GetString("fingerprint")
|
||||||
|
expectedFingerprint := existingFingerprint.GetString("fingerprint")
|
||||||
|
assert.Equal(t, expectedFingerprint, actualFingerprint, "Fingerprint should be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup for next test
|
||||||
|
if existingSystem != nil {
|
||||||
|
testHub.Delete(existingSystem)
|
||||||
|
}
|
||||||
|
if existingFingerprint != nil {
|
||||||
|
testHub.Delete(existingFingerprint)
|
||||||
|
}
|
||||||
|
// Clean up the new records
|
||||||
|
testHub.Delete(system)
|
||||||
|
testHub.Delete(fingerprint)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigMigrationScenario tests the specific migration scenario mentioned in the discussion
|
||||||
|
func TestConfigMigrationScenario(t *testing.T) {
|
||||||
|
testHub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer testHub.Cleanup()
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Simulate migration scenario: system exists with token from migration
|
||||||
|
existingSystem, err := tests.CreateRecord(testHub.App, "systems", map[string]any{
|
||||||
|
"name": "migrated-server",
|
||||||
|
"host": "migrated.example.com",
|
||||||
|
"port": 45876,
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
migrationToken := "migration-generated-token-123"
|
||||||
|
existingFingerprint, err := createConfigTestFingerprint(testHub.App, existingSystem.Id, migrationToken, "existing-fingerprint-from-agent")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// User exports config BEFORE this update (so no token field in YAML)
|
||||||
|
oldConfigYAML := `systems:
|
||||||
|
- name: "migrated-server"
|
||||||
|
host: "migrated.example.com"
|
||||||
|
port: 45876
|
||||||
|
users:
|
||||||
|
- "admin@example.com"`
|
||||||
|
|
||||||
|
// Write old config file and import
|
||||||
|
configPath := filepath.Join(testHub.DataDir(), "config.yml")
|
||||||
|
err = os.WriteFile(configPath, []byte(oldConfigYAML), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
event := &core.ServeEvent{App: testHub.App}
|
||||||
|
err = config.SyncSystems(event)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the original token is preserved
|
||||||
|
updatedFingerprint, err := testHub.FindRecordById("fingerprints", existingFingerprint.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
actualToken := updatedFingerprint.GetString("token")
|
||||||
|
assert.Equal(t, migrationToken, actualToken, "Migration token should be preserved when config doesn't specify a token")
|
||||||
|
|
||||||
|
// Verify fingerprint is also preserved
|
||||||
|
actualFingerprint := updatedFingerprint.GetString("fingerprint")
|
||||||
|
assert.Equal(t, "existing-fingerprint-from-agent", actualFingerprint, "Existing fingerprint should be preserved")
|
||||||
|
|
||||||
|
// Verify system still exists and is updated correctly
|
||||||
|
updatedSystem, err := testHub.FindRecordById("systems", existingSystem.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "migrated-server", updatedSystem.GetString("name"))
|
||||||
|
assert.Equal(t, "migrated.example.com", updatedSystem.GetString("host"))
|
||||||
|
}
|
||||||
104
beszel/internal/hub/expirymap/expirymap.go
Normal file
104
beszel/internal/hub/expirymap/expirymap.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package expirymap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type val[T any] struct {
|
||||||
|
value T
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpiryMap[T any] struct {
|
||||||
|
store *store.Store[string, *val[T]]
|
||||||
|
cleanupInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new expiry map with custom cleanup interval
|
||||||
|
func New[T any](cleanupInterval time.Duration) *ExpiryMap[T] {
|
||||||
|
m := &ExpiryMap[T]{
|
||||||
|
store: store.New(map[string]*val[T]{}),
|
||||||
|
cleanupInterval: cleanupInterval,
|
||||||
|
}
|
||||||
|
m.startCleaner()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a value with the given TTL
|
||||||
|
func (m *ExpiryMap[T]) Set(key string, value T, ttl time.Duration) {
|
||||||
|
m.store.Set(key, &val[T]{
|
||||||
|
value: value,
|
||||||
|
expires: time.Now().Add(ttl),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOk retrieves a value and checks if it exists and hasn't expired
|
||||||
|
// Performs lazy cleanup of expired entries on access
|
||||||
|
func (m *ExpiryMap[T]) GetOk(key string) (T, bool) {
|
||||||
|
value, ok := m.store.GetOk(key)
|
||||||
|
if !ok {
|
||||||
|
return *new(T), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired and perform lazy cleanup
|
||||||
|
if value.expires.Before(time.Now()) {
|
||||||
|
m.store.Remove(key)
|
||||||
|
return *new(T), false
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByValue retrieves a value by value
|
||||||
|
func (m *ExpiryMap[T]) GetByValue(val T) (key string, value T, ok bool) {
|
||||||
|
for key, v := range m.store.GetAll() {
|
||||||
|
if reflect.DeepEqual(v.value, val) {
|
||||||
|
// check if expired
|
||||||
|
if v.expires.Before(time.Now()) {
|
||||||
|
m.store.Remove(key)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return key, v.value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", *new(T), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove explicitly removes a key
|
||||||
|
func (m *ExpiryMap[T]) Remove(key string) {
|
||||||
|
m.store.Remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovebyValue removes a value by value
|
||||||
|
func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
|
||||||
|
for key, val := range m.store.GetAll() {
|
||||||
|
if reflect.DeepEqual(val.value, value) {
|
||||||
|
m.store.Remove(key)
|
||||||
|
return val.value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return *new(T), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCleaner runs the background cleanup process
|
||||||
|
func (m *ExpiryMap[T]) startCleaner() {
|
||||||
|
go func() {
|
||||||
|
tick := time.Tick(m.cleanupInterval)
|
||||||
|
for range tick {
|
||||||
|
m.cleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup removes all expired entries
|
||||||
|
func (m *ExpiryMap[T]) cleanup() {
|
||||||
|
now := time.Now()
|
||||||
|
for key, val := range m.store.GetAll() {
|
||||||
|
if val.expires.Before(now) {
|
||||||
|
m.store.Remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
477
beszel/internal/hub/expirymap/expirymap_test.go
Normal file
477
beszel/internal/hub/expirymap/expirymap_test.go
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package expirymap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Not using the following methods but are useful for testing
|
||||||
|
|
||||||
|
// TESTING: Has checks if a key exists and hasn't expired
|
||||||
|
func (m *ExpiryMap[T]) Has(key string) bool {
|
||||||
|
_, ok := m.GetOk(key)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// TESTING: Get retrieves a value, returns zero value if not found or expired
|
||||||
|
func (m *ExpiryMap[T]) Get(key string) T {
|
||||||
|
value, _ := m.GetOk(key)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// TESTING: Len returns the number of non-expired entries
|
||||||
|
func (m *ExpiryMap[T]) Len() int {
|
||||||
|
count := 0
|
||||||
|
now := time.Now()
|
||||||
|
for _, val := range m.store.Values() {
|
||||||
|
if val.expires.After(now) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_BasicOperations(t *testing.T) {
|
||||||
|
em := New[string](time.Hour)
|
||||||
|
|
||||||
|
// Test Set and GetOk
|
||||||
|
em.Set("key1", "value1", time.Hour)
|
||||||
|
value, ok := em.GetOk("key1")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "value1", value)
|
||||||
|
|
||||||
|
// Test Get
|
||||||
|
value = em.Get("key1")
|
||||||
|
assert.Equal(t, "value1", value)
|
||||||
|
|
||||||
|
// Test Has
|
||||||
|
assert.True(t, em.Has("key1"))
|
||||||
|
assert.False(t, em.Has("nonexistent"))
|
||||||
|
|
||||||
|
// Test Remove
|
||||||
|
em.Remove("key1")
|
||||||
|
assert.False(t, em.Has("key1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_Expiration(t *testing.T) {
|
||||||
|
em := New[string](time.Hour)
|
||||||
|
|
||||||
|
// Set a value with very short TTL
|
||||||
|
em.Set("shortlived", "value", time.Millisecond*10)
|
||||||
|
|
||||||
|
// Should exist immediately
|
||||||
|
assert.True(t, em.Has("shortlived"))
|
||||||
|
|
||||||
|
// Wait for expiration
|
||||||
|
time.Sleep(time.Millisecond * 20)
|
||||||
|
|
||||||
|
// Should be expired and automatically cleaned up on access
|
||||||
|
assert.False(t, em.Has("shortlived"))
|
||||||
|
value, ok := em.GetOk("shortlived")
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, "", value) // zero value for string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_LazyCleanup(t *testing.T) {
|
||||||
|
em := New[int](time.Hour)
|
||||||
|
|
||||||
|
// Set multiple values with short TTL
|
||||||
|
em.Set("key1", 1, time.Millisecond*10)
|
||||||
|
em.Set("key2", 2, time.Millisecond*10)
|
||||||
|
em.Set("key3", 3, time.Hour) // This one won't expire
|
||||||
|
|
||||||
|
// Wait for expiration
|
||||||
|
time.Sleep(time.Millisecond * 20)
|
||||||
|
|
||||||
|
// Access expired keys should trigger lazy cleanup
|
||||||
|
_, ok := em.GetOk("key1")
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
// Non-expired key should still exist
|
||||||
|
value, ok := em.GetOk("key3")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, 3, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_Len(t *testing.T) {
|
||||||
|
em := New[string](time.Hour)
|
||||||
|
|
||||||
|
// Initially empty
|
||||||
|
assert.Equal(t, 0, em.Len())
|
||||||
|
|
||||||
|
// Add some values
|
||||||
|
em.Set("key1", "value1", time.Hour)
|
||||||
|
em.Set("key2", "value2", time.Hour)
|
||||||
|
em.Set("key3", "value3", time.Millisecond*10) // Will expire soon
|
||||||
|
|
||||||
|
// Should count all initially
|
||||||
|
assert.Equal(t, 3, em.Len())
|
||||||
|
|
||||||
|
// Wait for one to expire
|
||||||
|
time.Sleep(time.Millisecond * 20)
|
||||||
|
|
||||||
|
// Len should reflect only non-expired entries
|
||||||
|
assert.Equal(t, 2, em.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_CustomInterval(t *testing.T) {
|
||||||
|
// Create with very short cleanup interval for testing
|
||||||
|
em := New[string](time.Millisecond * 50)
|
||||||
|
|
||||||
|
// Set a value that expires quickly
|
||||||
|
em.Set("test", "value", time.Millisecond*10)
|
||||||
|
|
||||||
|
// Should exist initially
|
||||||
|
assert.True(t, em.Has("test"))
|
||||||
|
|
||||||
|
// Wait for expiration + cleanup cycle
|
||||||
|
time.Sleep(time.Millisecond * 100)
|
||||||
|
|
||||||
|
// Should be cleaned up by background process
|
||||||
|
// Note: This test might be flaky due to timing, but demonstrates the concept
|
||||||
|
assert.False(t, em.Has("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_GenericTypes(t *testing.T) {
|
||||||
|
// Test with different types
|
||||||
|
t.Run("Int", func(t *testing.T) {
|
||||||
|
em := New[int](time.Hour)
|
||||||
|
|
||||||
|
em.Set("num", 42, time.Hour)
|
||||||
|
value, ok := em.GetOk("num")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, 42, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Struct", func(t *testing.T) {
|
||||||
|
type TestStruct struct {
|
||||||
|
Name string
|
||||||
|
Age int
|
||||||
|
}
|
||||||
|
|
||||||
|
em := New[TestStruct](time.Hour)
|
||||||
|
|
||||||
|
expected := TestStruct{Name: "John", Age: 30}
|
||||||
|
em.Set("person", expected, time.Hour)
|
||||||
|
|
||||||
|
value, ok := em.GetOk("person")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, expected, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Pointer", func(t *testing.T) {
|
||||||
|
em := New[*string](time.Hour)
|
||||||
|
|
||||||
|
str := "hello"
|
||||||
|
em.Set("ptr", &str, time.Hour)
|
||||||
|
|
||||||
|
value, ok := em.GetOk("ptr")
|
||||||
|
assert.True(t, ok)
|
||||||
|
require.NotNil(t, value)
|
||||||
|
assert.Equal(t, "hello", *value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_ZeroValues(t *testing.T) {
|
||||||
|
em := New[string](time.Hour)
|
||||||
|
|
||||||
|
// Test getting non-existent key returns zero value
|
||||||
|
value := em.Get("nonexistent")
|
||||||
|
assert.Equal(t, "", value)
|
||||||
|
|
||||||
|
// Test getting expired key returns zero value
|
||||||
|
em.Set("expired", "value", time.Millisecond*10)
|
||||||
|
time.Sleep(time.Millisecond * 20)
|
||||||
|
|
||||||
|
value = em.Get("expired")
|
||||||
|
assert.Equal(t, "", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_Concurrent(t *testing.T) {
|
||||||
|
em := New[int](time.Hour)
|
||||||
|
|
||||||
|
// Simple concurrent access test
|
||||||
|
done := make(chan bool, 2)
|
||||||
|
|
||||||
|
// Writer goroutine
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
em.Set("key", i, time.Hour)
|
||||||
|
time.Sleep(time.Microsecond)
|
||||||
|
}
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Reader goroutine
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
_ = em.Get("key")
|
||||||
|
time.Sleep(time.Microsecond)
|
||||||
|
}
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for both to complete
|
||||||
|
<-done
|
||||||
|
<-done
|
||||||
|
|
||||||
|
// Should not panic and should have some value
|
||||||
|
assert.True(t, em.Has("key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_GetByValue(t *testing.T) {
|
||||||
|
em := New[string](time.Hour)
|
||||||
|
|
||||||
|
// Test getting by value when value exists
|
||||||
|
em.Set("key1", "value1", time.Hour)
|
||||||
|
em.Set("key2", "value2", time.Hour)
|
||||||
|
em.Set("key3", "value1", time.Hour) // Duplicate value - should return first match
|
||||||
|
|
||||||
|
// Test successful retrieval
|
||||||
|
key, value, ok := em.GetByValue("value1")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "value1", value)
|
||||||
|
assert.Contains(t, []string{"key1", "key3"}, key) // Should be one of the keys with this value
|
||||||
|
|
||||||
|
// Test retrieval of unique value
|
||||||
|
key, value, ok = em.GetByValue("value2")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "value2", value)
|
||||||
|
assert.Equal(t, "key2", key)
|
||||||
|
|
||||||
|
// Test getting non-existent value
|
||||||
|
key, value, ok = em.GetByValue("nonexistent")
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, "", value) // zero value for string
|
||||||
|
assert.Equal(t, "", key) // zero value for string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_GetByValue_Expiration(t *testing.T) {
|
||||||
|
em := New[string](time.Hour)
|
||||||
|
|
||||||
|
// Set a value with short TTL
|
||||||
|
em.Set("shortkey", "shortvalue", time.Millisecond*10)
|
||||||
|
em.Set("longkey", "longvalue", time.Hour)
|
||||||
|
|
||||||
|
// Should find the short-lived value initially
|
||||||
|
key, value, ok := em.GetByValue("shortvalue")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "shortvalue", value)
|
||||||
|
assert.Equal(t, "shortkey", key)
|
||||||
|
|
||||||
|
// Wait for expiration
|
||||||
|
time.Sleep(time.Millisecond * 20)
|
||||||
|
|
||||||
|
// Should not find expired value and should trigger lazy cleanup
|
||||||
|
key, value, ok = em.GetByValue("shortvalue")
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, "", value)
|
||||||
|
assert.Equal(t, "", key)
|
||||||
|
|
||||||
|
// Should still find non-expired value
|
||||||
|
key, value, ok = em.GetByValue("longvalue")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "longvalue", value)
|
||||||
|
assert.Equal(t, "longkey", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_GetByValue_GenericTypes(t *testing.T) {
|
||||||
|
t.Run("Int", func(t *testing.T) {
|
||||||
|
em := New[int](time.Hour)
|
||||||
|
|
||||||
|
em.Set("num1", 42, time.Hour)
|
||||||
|
em.Set("num2", 84, time.Hour)
|
||||||
|
|
||||||
|
key, value, ok := em.GetByValue(42)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, 42, value)
|
||||||
|
assert.Equal(t, "num1", key)
|
||||||
|
|
||||||
|
key, value, ok = em.GetByValue(99)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, 0, value)
|
||||||
|
assert.Equal(t, "", key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Struct", func(t *testing.T) {
|
||||||
|
type TestStruct struct {
|
||||||
|
Name string
|
||||||
|
Age int
|
||||||
|
}
|
||||||
|
|
||||||
|
em := New[TestStruct](time.Hour)
|
||||||
|
|
||||||
|
person1 := TestStruct{Name: "John", Age: 30}
|
||||||
|
person2 := TestStruct{Name: "Jane", Age: 25}
|
||||||
|
|
||||||
|
em.Set("person1", person1, time.Hour)
|
||||||
|
em.Set("person2", person2, time.Hour)
|
||||||
|
|
||||||
|
key, value, ok := em.GetByValue(person1)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, person1, value)
|
||||||
|
assert.Equal(t, "person1", key)
|
||||||
|
|
||||||
|
nonexistent := TestStruct{Name: "Bob", Age: 40}
|
||||||
|
key, value, ok = em.GetByValue(nonexistent)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, TestStruct{}, value)
|
||||||
|
assert.Equal(t, "", key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_RemoveValue(t *testing.T) {
|
||||||
|
em := New[string](time.Hour)
|
||||||
|
|
||||||
|
// Test removing existing value
|
||||||
|
em.Set("key1", "value1", time.Hour)
|
||||||
|
em.Set("key2", "value2", time.Hour)
|
||||||
|
em.Set("key3", "value1", time.Hour) // Duplicate value
|
||||||
|
|
||||||
|
// Remove by value should remove one instance
|
||||||
|
removedValue, ok := em.RemovebyValue("value1")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "value1", removedValue)
|
||||||
|
|
||||||
|
// Should still have the other instance or value2
|
||||||
|
assert.True(t, em.Has("key2")) // value2 should still exist
|
||||||
|
|
||||||
|
// Check if one of the duplicate values was removed
|
||||||
|
// At least one key with "value1" should be gone
|
||||||
|
key1Exists := em.Has("key1")
|
||||||
|
key3Exists := em.Has("key3")
|
||||||
|
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
|
||||||
|
assert.True(t, key1Exists || key3Exists) // At least one should be gone
|
||||||
|
|
||||||
|
// Test removing non-existent value
|
||||||
|
removedValue, ok = em.RemovebyValue("nonexistent")
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, "", removedValue) // zero value for string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_RemoveValue_GenericTypes(t *testing.T) {
|
||||||
|
t.Run("Int", func(t *testing.T) {
|
||||||
|
em := New[int](time.Hour)
|
||||||
|
|
||||||
|
em.Set("num1", 42, time.Hour)
|
||||||
|
em.Set("num2", 84, time.Hour)
|
||||||
|
|
||||||
|
// Remove existing value
|
||||||
|
removedValue, ok := em.RemovebyValue(42)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, 42, removedValue)
|
||||||
|
assert.False(t, em.Has("num1"))
|
||||||
|
assert.True(t, em.Has("num2"))
|
||||||
|
|
||||||
|
// Remove non-existent value
|
||||||
|
removedValue, ok = em.RemovebyValue(99)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, 0, removedValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Struct", func(t *testing.T) {
|
||||||
|
type TestStruct struct {
|
||||||
|
Name string
|
||||||
|
Age int
|
||||||
|
}
|
||||||
|
|
||||||
|
em := New[TestStruct](time.Hour)
|
||||||
|
|
||||||
|
person1 := TestStruct{Name: "John", Age: 30}
|
||||||
|
person2 := TestStruct{Name: "Jane", Age: 25}
|
||||||
|
|
||||||
|
em.Set("person1", person1, time.Hour)
|
||||||
|
em.Set("person2", person2, time.Hour)
|
||||||
|
|
||||||
|
// Remove existing struct
|
||||||
|
removedValue, ok := em.RemovebyValue(person1)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, person1, removedValue)
|
||||||
|
assert.False(t, em.Has("person1"))
|
||||||
|
assert.True(t, em.Has("person2"))
|
||||||
|
|
||||||
|
// Remove non-existent struct
|
||||||
|
nonexistent := TestStruct{Name: "Bob", Age: 40}
|
||||||
|
removedValue, ok = em.RemovebyValue(nonexistent)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, TestStruct{}, removedValue)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
|
||||||
|
em := New[string](time.Hour)
|
||||||
|
|
||||||
|
// Set values with different TTLs
|
||||||
|
em.Set("key1", "value1", time.Millisecond*10) // Will expire
|
||||||
|
em.Set("key2", "value2", time.Hour) // Won't expire
|
||||||
|
em.Set("key3", "value1", time.Hour) // Won't expire, duplicate value
|
||||||
|
|
||||||
|
// Wait for first value to expire
|
||||||
|
time.Sleep(time.Millisecond * 20)
|
||||||
|
|
||||||
|
// Try to remove the expired value - should remove one of the "value1" entries
|
||||||
|
removedValue, ok := em.RemovebyValue("value1")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "value1", removedValue)
|
||||||
|
|
||||||
|
// Should still have key2 (different value)
|
||||||
|
assert.True(t, em.Has("key2"))
|
||||||
|
|
||||||
|
// Should have removed one of the "value1" entries (either key1 or key3)
|
||||||
|
// But we can't predict which one due to map iteration order
|
||||||
|
key1Exists := em.Has("key1")
|
||||||
|
key3Exists := em.Has("key3")
|
||||||
|
|
||||||
|
// Exactly one of key1 or key3 should be gone
|
||||||
|
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
|
||||||
|
assert.True(t, key1Exists || key3Exists) // At least one should still exist
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
|
||||||
|
em := New[string](time.Hour)
|
||||||
|
|
||||||
|
// Test integration of GetByValue and RemoveValue
|
||||||
|
em.Set("key1", "shared", time.Hour)
|
||||||
|
em.Set("key2", "unique", time.Hour)
|
||||||
|
em.Set("key3", "shared", time.Hour)
|
||||||
|
|
||||||
|
// Find shared value
|
||||||
|
key, value, ok := em.GetByValue("shared")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "shared", value)
|
||||||
|
assert.Contains(t, []string{"key1", "key3"}, key)
|
||||||
|
|
||||||
|
// Remove shared value
|
||||||
|
removedValue, ok := em.RemovebyValue("shared")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "shared", removedValue)
|
||||||
|
|
||||||
|
// Should still be able to find the other shared value
|
||||||
|
key, value, ok = em.GetByValue("shared")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "shared", value)
|
||||||
|
assert.Contains(t, []string{"key1", "key3"}, key)
|
||||||
|
|
||||||
|
// Remove the other shared value
|
||||||
|
removedValue, ok = em.RemovebyValue("shared")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "shared", removedValue)
|
||||||
|
|
||||||
|
// Should not find shared value anymore
|
||||||
|
key, value, ok = em.GetByValue("shared")
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, "", value)
|
||||||
|
assert.Equal(t, "", key)
|
||||||
|
|
||||||
|
// Unique value should still exist
|
||||||
|
key, value, ok = em.GetByValue("unique")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "unique", value)
|
||||||
|
assert.Equal(t, "key2", key)
|
||||||
|
}
|
||||||
@@ -4,19 +4,21 @@ package hub
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/alerts"
|
"beszel/internal/alerts"
|
||||||
|
"beszel/internal/hub/config"
|
||||||
"beszel/internal/hub/systems"
|
"beszel/internal/hub/systems"
|
||||||
"beszel/internal/records"
|
"beszel/internal/records"
|
||||||
"beszel/internal/users"
|
"beszel/internal/users"
|
||||||
"beszel/site"
|
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"io/fs"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -30,6 +32,7 @@ type Hub struct {
|
|||||||
rm *records.RecordManager
|
rm *records.RecordManager
|
||||||
sm *systems.SystemManager
|
sm *systems.SystemManager
|
||||||
pubKey string
|
pubKey string
|
||||||
|
signer ssh.Signer
|
||||||
appURL string
|
appURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,14 +59,13 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) StartHub() error {
|
func (h *Hub) StartHub() error {
|
||||||
|
|
||||||
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||||
// initialize settings / collections
|
// initialize settings / collections
|
||||||
if err := h.initialize(e); err != nil {
|
if err := h.initialize(e); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// sync systems with config
|
// sync systems with config
|
||||||
if err := syncSystemsWithConfig(e); err != nil {
|
if err := config.SyncSystems(e); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// register api routes
|
// register api routes
|
||||||
@@ -110,6 +112,11 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
// set URL if BASE_URL env is set
|
// set URL if BASE_URL env is set
|
||||||
if h.appURL != "" {
|
if h.appURL != "" {
|
||||||
settings.Meta.AppURL = h.appURL
|
settings.Meta.AppURL = h.appURL
|
||||||
|
} else {
|
||||||
|
h.appURL = settings.Meta.AppURL
|
||||||
|
}
|
||||||
|
if err := e.App.Save(settings); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
// set auth settings
|
// set auth settings
|
||||||
usersCollection, err := e.App.FindCollectionByNameOrId("users")
|
usersCollection, err := e.App.FindCollectionByNameOrId("users")
|
||||||
@@ -156,57 +163,9 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startServer starts the server for the Beszel (not PocketBase)
|
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
|
||||||
// TODO: exclude dev server from production binary
|
|
||||||
switch h.IsDev() {
|
|
||||||
case true:
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
|
||||||
Scheme: "http",
|
|
||||||
Host: "localhost:5173",
|
|
||||||
})
|
|
||||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
|
||||||
proxy.ServeHTTP(e.Response, e.Request)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
// parse app url
|
|
||||||
parsedURL, err := url.Parse(h.appURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// fix base paths in html if using subpath
|
|
||||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
|
||||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
|
||||||
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
|
|
||||||
indexContent = strings.Replace(indexContent, "{{V}}", beszel.Version, 1)
|
|
||||||
// set up static asset serving
|
|
||||||
staticPaths := [2]string{"/static/", "/assets/"}
|
|
||||||
serveStatic := apis.Static(site.DistDirFS, false)
|
|
||||||
// get CSP configuration
|
|
||||||
csp, cspExists := GetEnv("CSP")
|
|
||||||
// add route
|
|
||||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
|
||||||
// serve static assets if path is in staticPaths
|
|
||||||
for i := range staticPaths {
|
|
||||||
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
|
||||||
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
|
||||||
return serveStatic(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cspExists {
|
|
||||||
e.Response.Header().Del("X-Frame-Options")
|
|
||||||
e.Response.Header().Set("Content-Security-Policy", csp)
|
|
||||||
}
|
|
||||||
return e.HTML(http.StatusOK, indexContent)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerCronJobs sets up scheduled tasks
|
// registerCronJobs sets up scheduled tasks
|
||||||
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
||||||
// delete old records once every hour
|
// delete old system_stats and alerts_history records once every hour
|
||||||
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
||||||
// create longer records every 10 minutes
|
// create longer records every 10 minutes
|
||||||
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
||||||
@@ -215,97 +174,128 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
|||||||
|
|
||||||
// custom api routes
|
// custom api routes
|
||||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||||
// returns public key and version
|
// auth protected routes
|
||||||
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
|
apiAuth := se.Router.Group("/api/beszel")
|
||||||
info, _ := e.RequestInfo()
|
apiAuth.Bind(apis.RequireAuth())
|
||||||
if info.Auth == nil {
|
// auth optional routes
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
apiNoAuth := se.Router.Group("/api/beszel")
|
||||||
}
|
|
||||||
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
// create first user endpoint only needed if no users exist
|
||||||
})
|
if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
|
||||||
|
apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
|
||||||
|
}
|
||||||
// check if first time setup on login page
|
// check if first time setup on login page
|
||||||
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
|
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
|
||||||
total, err := h.CountRecords("users")
|
total, err := e.App.CountRecords("users")
|
||||||
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
||||||
})
|
})
|
||||||
|
// get public key and version
|
||||||
|
apiAuth.GET("/getkey", func(e *core.RequestEvent) error {
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
||||||
|
})
|
||||||
// send test notification
|
// send test notification
|
||||||
se.Router.GET("/api/beszel/send-test-notification", h.SendTestNotification)
|
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||||
// API endpoint to get config.yml content
|
// get config.yml content
|
||||||
se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
|
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
||||||
// create first user endpoint only needed if no users exist
|
// handle agent websocket connection
|
||||||
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
|
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||||
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
|
// get or create universal tokens
|
||||||
}
|
apiAuth.GET("/universal-token", h.getUniversalToken)
|
||||||
|
// update / delete user alerts
|
||||||
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generates key pair if it doesn't exist and returns private key bytes
|
// Handler for universal token API endpoint (create, read, delete)
|
||||||
func (h *Hub) GetSSHKey() ([]byte, error) {
|
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||||
dataDir := h.DataDir()
|
tokenMap := universalTokenMap.GetMap()
|
||||||
// check if the key pair already exists
|
userID := e.Auth.Id
|
||||||
existingKey, err := os.ReadFile(dataDir + "/id_ed25519")
|
query := e.Request.URL.Query()
|
||||||
if err == nil {
|
token := query.Get("token")
|
||||||
if pubKey, err := os.ReadFile(h.DataDir() + "/id_ed25519.pub"); err == nil {
|
|
||||||
h.pubKey = strings.TrimSuffix(string(pubKey), "\n")
|
if token == "" {
|
||||||
|
// return existing token if it exists
|
||||||
|
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
|
||||||
}
|
}
|
||||||
// return existing private key
|
// if no token is provided, generate a new one
|
||||||
return existingKey, nil
|
token = uuid.New().String()
|
||||||
|
}
|
||||||
|
response := map[string]any{"token": token}
|
||||||
|
|
||||||
|
switch query.Get("enable") {
|
||||||
|
case "1":
|
||||||
|
tokenMap.Set(token, userID, time.Hour)
|
||||||
|
case "0":
|
||||||
|
tokenMap.RemovebyValue(userID)
|
||||||
|
}
|
||||||
|
_, response["active"] = tokenMap.GetOk(token)
|
||||||
|
return e.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generates key pair if it doesn't exist and returns signer
|
||||||
|
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
|
||||||
|
if h.signer != nil {
|
||||||
|
return h.signer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = h.DataDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKeyPath := path.Join(dataDir, "id_ed25519")
|
||||||
|
|
||||||
|
// check if the key pair already exists
|
||||||
|
existingKey, err := os.ReadFile(privateKeyPath)
|
||||||
|
if err == nil {
|
||||||
|
private, err := ssh.ParsePrivateKey(existingKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse private key: %s", err)
|
||||||
|
}
|
||||||
|
pubKeyBytes := ssh.MarshalAuthorizedKey(private.PublicKey())
|
||||||
|
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
|
||||||
|
return private, nil
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
// File exists but couldn't be read for some other reason
|
||||||
|
return nil, fmt.Errorf("failed to read %s: %w", privateKeyPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the Ed25519 key pair
|
// Generate the Ed25519 key pair
|
||||||
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
_, privKey, err := ed25519.GenerateKey(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// h.Logger().Error("Error generating key pair:", "err", err.Error())
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
privKeyPem, err := ssh.MarshalPrivateKey(privKey, "")
|
||||||
// Get the private key in OpenSSH format
|
|
||||||
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "")
|
|
||||||
if err != nil {
|
|
||||||
// h.Logger().Error("Error marshaling private key:", "err", err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the private key to a file
|
|
||||||
privateFile, err := os.Create(dataDir + "/id_ed25519")
|
|
||||||
if err != nil {
|
|
||||||
// h.Logger().Error("Error creating private key file:", "err", err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer privateFile.Close()
|
|
||||||
|
|
||||||
if err := pem.Encode(privateFile, privKeyBytes); err != nil {
|
|
||||||
// h.Logger().Error("Error writing private key to file:", "err", err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the public key in OpenSSH format
|
|
||||||
publicKey, err := ssh.NewPublicKey(pubKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey)
|
if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(privKeyPem), 0600); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write private key to %q: err: %w", privateKeyPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are fine to ignore the errors on, as we've literally just created a crypto.PublicKey | crypto.Signer
|
||||||
|
sshPrivate, _ := ssh.NewSignerFromSigner(privKey)
|
||||||
|
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPrivate.PublicKey())
|
||||||
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
|
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
|
||||||
|
|
||||||
// Save the public key to a file
|
h.Logger().Info("ed25519 key pair generated successfully.")
|
||||||
publicFile, err := os.Create(dataDir + "/id_ed25519.pub")
|
h.Logger().Info("Saved to: " + privateKeyPath)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer publicFile.Close()
|
|
||||||
|
|
||||||
if _, err := publicFile.Write(pubKeyBytes); err != nil {
|
return sshPrivate, err
|
||||||
return nil, err
|
}
|
||||||
}
|
|
||||||
|
// MakeLink formats a link with the app URL and path segments.
|
||||||
h.Logger().Info("ed25519 SSH key pair generated successfully.")
|
// Only path segments should be provided.
|
||||||
h.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519")
|
func (h *Hub) MakeLink(parts ...string) string {
|
||||||
h.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub")
|
base := strings.TrimSuffix(h.Settings().Meta.AppURL, "/")
|
||||||
|
for _, part := range parts {
|
||||||
existingKey, err = os.ReadFile(dataDir + "/id_ed25519")
|
if part == "" {
|
||||||
if err == nil {
|
continue
|
||||||
return existingKey, nil
|
}
|
||||||
}
|
base = fmt.Sprintf("%s/%s", base, url.PathEscape(part))
|
||||||
return nil, err
|
}
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
|
|||||||
603
beszel/internal/hub/hub_test.go
Normal file
603
beszel/internal/hub/hub_test.go
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package hub_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
beszelTests "beszel/internal/tests"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
|
func jsonReader(v any) io.Reader {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeLink(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
appURL string
|
||||||
|
parts []string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no parts, no trailing slash in AppURL",
|
||||||
|
appURL: "http://localhost:8090",
|
||||||
|
parts: []string{},
|
||||||
|
expected: "http://localhost:8090",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no parts, with trailing slash in AppURL",
|
||||||
|
appURL: "http://localhost:8090/",
|
||||||
|
parts: []string{},
|
||||||
|
expected: "http://localhost:8090", // TrimSuffix should handle the trailing slash
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one part",
|
||||||
|
appURL: "http://example.com",
|
||||||
|
parts: []string{"one"},
|
||||||
|
expected: "http://example.com/one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple parts",
|
||||||
|
appURL: "http://example.com",
|
||||||
|
parts: []string{"alpha", "beta", "gamma"},
|
||||||
|
expected: "http://example.com/alpha/beta/gamma",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parts with spaces needing escaping",
|
||||||
|
appURL: "http://example.com",
|
||||||
|
parts: []string{"path with spaces", "another part"},
|
||||||
|
expected: "http://example.com/path%20with%20spaces/another%20part",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parts with slashes needing escaping",
|
||||||
|
appURL: "http://example.com",
|
||||||
|
parts: []string{"a/b", "c"},
|
||||||
|
expected: "http://example.com/a%2Fb/c", // url.PathEscape escapes '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AppURL with subpath, no trailing slash",
|
||||||
|
appURL: "http://localhost/sub",
|
||||||
|
parts: []string{"resource"},
|
||||||
|
expected: "http://localhost/sub/resource",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AppURL with subpath, with trailing slash",
|
||||||
|
appURL: "http://localhost/sub/",
|
||||||
|
parts: []string{"item"},
|
||||||
|
expected: "http://localhost/sub/item",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty parts in the middle",
|
||||||
|
appURL: "http://localhost",
|
||||||
|
parts: []string{"first", "", "third"},
|
||||||
|
expected: "http://localhost/first/third",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading and trailing empty parts",
|
||||||
|
appURL: "http://localhost",
|
||||||
|
parts: []string{"", "path", ""},
|
||||||
|
expected: "http://localhost/path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parts with various special characters",
|
||||||
|
appURL: "https://test.dev/",
|
||||||
|
parts: []string{"p@th?", "key=value&"},
|
||||||
|
expected: "https://test.dev/p@th%3F/key=value&",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Store original app URL and restore it after the test
|
||||||
|
originalAppURL := hub.Settings().Meta.AppURL
|
||||||
|
hub.Settings().Meta.AppURL = tt.appURL
|
||||||
|
defer func() { hub.Settings().Meta.AppURL = originalAppURL }()
|
||||||
|
|
||||||
|
got := hub.MakeLink(tt.parts...)
|
||||||
|
assert.Equal(t, tt.expected, got, "MakeLink generated URL does not match expected")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSSHKey(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
|
||||||
|
// Test Case 1: Key generation (no existing key)
|
||||||
|
t.Run("KeyGeneration", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Ensure pubKey is initially empty or different to ensure GetSSHKey sets it
|
||||||
|
hub.SetPubkey("")
|
||||||
|
|
||||||
|
signer, err := hub.GetSSHKey(tempDir)
|
||||||
|
assert.NoError(t, err, "GetSSHKey should not error when generating a new key")
|
||||||
|
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer")
|
||||||
|
|
||||||
|
// Check if private key file was created
|
||||||
|
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
|
||||||
|
info, err := os.Stat(privateKeyPath)
|
||||||
|
assert.NoError(t, err, "Private key file should be created")
|
||||||
|
assert.False(t, info.IsDir(), "Private key path should be a file, not a directory")
|
||||||
|
|
||||||
|
// Check if h.pubKey was set
|
||||||
|
assert.NotEmpty(t, hub.GetPubkey(), "h.pubKey should be set after key generation")
|
||||||
|
assert.True(t, strings.HasPrefix(hub.GetPubkey(), "ssh-ed25519 "), "h.pubKey should start with 'ssh-ed25519 '")
|
||||||
|
|
||||||
|
// Verify the generated private key is parsable
|
||||||
|
keyData, err := os.ReadFile(privateKeyPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = ssh.ParsePrivateKey(keyData)
|
||||||
|
assert.NoError(t, err, "Generated private key should be parsable by ssh.ParsePrivateKey")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 2: Existing key
|
||||||
|
t.Run("ExistingKey", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Manually create a valid key pair for the test
|
||||||
|
rawPubKey, rawPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err, "Failed to generate raw ed25519 key pair for pre-existing key test")
|
||||||
|
|
||||||
|
// Marshal the private key into OpenSSH PEM format
|
||||||
|
pemBlock, err := ssh.MarshalPrivateKey(rawPrivKey, "")
|
||||||
|
require.NoError(t, err, "Failed to marshal private key to PEM block for pre-existing key test")
|
||||||
|
|
||||||
|
privateKeyBytes := pem.EncodeToMemory(pemBlock)
|
||||||
|
require.NotNil(t, privateKeyBytes, "PEM encoded private key bytes should not be nil")
|
||||||
|
|
||||||
|
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
|
||||||
|
err = os.WriteFile(privateKeyPath, privateKeyBytes, 0600)
|
||||||
|
require.NoError(t, err, "Failed to write pre-existing private key")
|
||||||
|
|
||||||
|
// Determine the expected public key string
|
||||||
|
sshPubKey, err := ssh.NewPublicKey(rawPubKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
expectedPubKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey)))
|
||||||
|
|
||||||
|
// Reset h.pubKey to ensure it's set by GetSSHKey from the file
|
||||||
|
hub.SetPubkey("")
|
||||||
|
|
||||||
|
signer, err := hub.GetSSHKey(tempDir)
|
||||||
|
assert.NoError(t, err, "GetSSHKey should not error when reading an existing key")
|
||||||
|
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer for an existing key")
|
||||||
|
|
||||||
|
// Check if h.pubKey was set correctly to the public key from the file
|
||||||
|
assert.Equal(t, expectedPubKeyStr, hub.GetPubkey(), "h.pubKey should match the existing public key")
|
||||||
|
|
||||||
|
// Verify the signer's public key matches the original public key
|
||||||
|
signerPubKey := signer.PublicKey()
|
||||||
|
marshaledSignerPubKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signerPubKey)))
|
||||||
|
assert.Equal(t, expectedPubKeyStr, marshaledSignerPubKey, "Signer's public key should match the existing public key")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 3: Error cases
|
||||||
|
t.Run("ErrorCases", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupFunc func(dir string) error
|
||||||
|
errorCheck func(t *testing.T, err error)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CorruptedKey",
|
||||||
|
setupFunc: func(dir string) error {
|
||||||
|
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte("this is not a valid SSH key"), 0600)
|
||||||
|
},
|
||||||
|
errorCheck: func(t *testing.T, err error) {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "ssh: no key found")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PermissionDenied",
|
||||||
|
setupFunc: func(dir string) error {
|
||||||
|
// Create the key file
|
||||||
|
keyPath := filepath.Join(dir, "id_ed25519")
|
||||||
|
if err := os.WriteFile(keyPath, []byte("dummy content"), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Make it read-only (can't be opened for writing in case a new key needs to be written)
|
||||||
|
return os.Chmod(keyPath, 0400)
|
||||||
|
},
|
||||||
|
errorCheck: func(t *testing.T, err error) {
|
||||||
|
// On read-only key, the parser will attempt to parse it and fail with "ssh: no key found"
|
||||||
|
assert.Error(t, err)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "EmptyFile",
|
||||||
|
setupFunc: func(dir string) error {
|
||||||
|
// Create an empty file
|
||||||
|
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte{}, 0600)
|
||||||
|
},
|
||||||
|
errorCheck: func(t *testing.T, err error) {
|
||||||
|
assert.Error(t, err)
|
||||||
|
// The error from attempting to parse an empty file
|
||||||
|
assert.Contains(t, err.Error(), "ssh: no key found")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup the test case
|
||||||
|
err := tc.setupFunc(tempDir)
|
||||||
|
require.NoError(t, err, "Setup failed")
|
||||||
|
|
||||||
|
// Reset h.pubKey before each test case
|
||||||
|
hub.SetPubkey("")
|
||||||
|
|
||||||
|
// Attempt to get SSH key
|
||||||
|
_, err = hub.GetSSHKey(tempDir)
|
||||||
|
|
||||||
|
// Verify the error
|
||||||
|
tc.errorCheck(t, err)
|
||||||
|
|
||||||
|
// Check that pubKey was not set in error cases
|
||||||
|
assert.Empty(t, hub.GetPubkey(), "h.pubKey should not be set if there was an error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiRoutesAuthentication(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
// Create test user and get auth token
|
||||||
|
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
||||||
|
require.NoError(t, err, "Failed to create test user")
|
||||||
|
|
||||||
|
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"role": "admin",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to create admin user")
|
||||||
|
adminUserToken, err := adminUser.NewAuthToken()
|
||||||
|
|
||||||
|
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
|
||||||
|
// "email": "superuser@example.com",
|
||||||
|
// "password": "password123",
|
||||||
|
// })
|
||||||
|
// require.NoError(t, err, "Failed to create superuser")
|
||||||
|
|
||||||
|
userToken, err := user.NewAuthToken()
|
||||||
|
require.NoError(t, err, "Failed to create auth token")
|
||||||
|
|
||||||
|
// Create test system for user-alerts endpoints
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to create test system")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
// Auth Protected Routes - Should require authentication
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - no auth should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - with auth should succeed",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"sending message"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /config-yaml - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/config-yaml",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /config-yaml - with user auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/config-yaml",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"Requires admin"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /config-yaml - with admin auth should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/config-yaml",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": adminUserToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"test-system"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /universal-token - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/universal-token",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /universal-token - with auth should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/universal-token",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"active", "token"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - no auth should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - with auth should succeed",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE /user-alerts - no auth should fail",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE /user-alerts - with auth should succeed",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
// Create an alert to delete
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth Optional Routes - Should work without authentication
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - with auth should also succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"key\":", "\"v\":"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /first-run - no auth should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/first-run",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"firstRun\":false"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /first-run - with auth should also succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/first-run",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"firstRun\":false"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/agent-connect",
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - invalid auth token should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": "invalid-token",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - invalid auth token should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": "invalid-token",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserEndpointAvailability(t *testing.T) {
|
||||||
|
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Ensure no users exist
|
||||||
|
userCount, err := hub.CountRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, userCount, "Should start with no users")
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := beszelTests.ApiScenario{
|
||||||
|
Name: "POST /create-user - should be available when no users exist",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/create-user",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"email": "firstuser@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"User created"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.Test(t)
|
||||||
|
|
||||||
|
// Verify user was created
|
||||||
|
userCount, err = hub.CountRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, userCount, "Should have created one user")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a user first
|
||||||
|
_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := beszelTests.ApiScenario{
|
||||||
|
Name: "POST /create-user - should not be available when users exist",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/create-user",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"email": "another@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{"wasn't found"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.Test(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
21
beszel/internal/hub/hub_test_helpers.go
Normal file
21
beszel/internal/hub/hub_test_helpers.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import "beszel/internal/hub/systems"
|
||||||
|
|
||||||
|
// TESTING ONLY: GetSystemManager returns the system manager
|
||||||
|
func (h *Hub) GetSystemManager() *systems.SystemManager {
|
||||||
|
return h.sm
|
||||||
|
}
|
||||||
|
|
||||||
|
// TESTING ONLY: GetPubkey returns the public key
|
||||||
|
func (h *Hub) GetPubkey() string {
|
||||||
|
return h.pubKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// TESTING ONLY: SetPubkey sets the public key
|
||||||
|
func (h *Hub) SetPubkey(pubkey string) {
|
||||||
|
h.pubKey = pubkey
|
||||||
|
}
|
||||||
79
beszel/internal/hub/server_development.go
Normal file
79
beszel/internal/hub/server_development.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
//go:build development
|
||||||
|
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wraps http.RoundTripper to modify dev proxy HTML responses
|
||||||
|
type responseModifier struct {
|
||||||
|
transport http.RoundTripper
|
||||||
|
hub *Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := rm.transport.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
// Only modify HTML responses
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(contentType, "text/html") {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
// Create a new response with the modified body
|
||||||
|
modifiedBody := rm.modifyHTML(string(body))
|
||||||
|
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||||
|
resp.ContentLength = int64(len(modifiedBody))
|
||||||
|
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *responseModifier) modifyHTML(html string) string {
|
||||||
|
parsedURL, err := url.Parse(rm.hub.appURL)
|
||||||
|
if err != nil {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
// fix base paths in html if using subpath
|
||||||
|
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||||
|
html = strings.ReplaceAll(html, "./", basePath)
|
||||||
|
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||||
|
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServer sets up the development server for Beszel
|
||||||
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
|
slog.Info("starting server", "appURL", h.appURL)
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: "localhost:5173",
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy.Transport = &responseModifier{
|
||||||
|
transport: http.DefaultTransport,
|
||||||
|
hub: h,
|
||||||
|
}
|
||||||
|
|
||||||
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
proxy.ServeHTTP(e.Response, e.Request)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
51
beszel/internal/hub/server_production.go
Normal file
51
beszel/internal/hub/server_production.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
//go:build !development
|
||||||
|
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/site"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startServer sets up the production server for Beszel
|
||||||
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
|
// parse app url
|
||||||
|
parsedURL, err := url.Parse(h.appURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// fix base paths in html if using subpath
|
||||||
|
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||||
|
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||||
|
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
||||||
|
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||||
|
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
||||||
|
// set up static asset serving
|
||||||
|
staticPaths := [2]string{"/static/", "/assets/"}
|
||||||
|
serveStatic := apis.Static(site.DistDirFS, false)
|
||||||
|
// get CSP configuration
|
||||||
|
csp, cspExists := GetEnv("CSP")
|
||||||
|
// add route
|
||||||
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
// serve static assets if path is in staticPaths
|
||||||
|
for i := range staticPaths {
|
||||||
|
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
||||||
|
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
||||||
|
return serveStatic(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cspExists {
|
||||||
|
e.Response.Header().Del("X-Frame-Options")
|
||||||
|
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||||
|
}
|
||||||
|
return e.HTML(http.StatusOK, html)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
387
beszel/internal/hub/systems/system.go
Normal file
387
beszel/internal/hub/systems/system.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"beszel/internal/hub/ws"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type System struct {
|
||||||
|
Id string `db:"id"`
|
||||||
|
Host string `db:"host"`
|
||||||
|
Port string `db:"port"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
manager *SystemManager // Manager that this system belongs to
|
||||||
|
client *ssh.Client // SSH client for fetching data
|
||||||
|
data *system.CombinedData // system data from agent
|
||||||
|
ctx context.Context // Context for stopping the updater
|
||||||
|
cancel context.CancelFunc // Stops and removes system from updater
|
||||||
|
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
||||||
|
agentVersion semver.Version // Agent version
|
||||||
|
updateTicker *time.Ticker // Ticker for updating the system
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||||
|
system := &System{
|
||||||
|
Id: systemId,
|
||||||
|
data: &system.CombinedData{},
|
||||||
|
}
|
||||||
|
system.ctx, system.cancel = system.getContext()
|
||||||
|
return system
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartUpdater starts the system updater.
|
||||||
|
// It first fetches the data from the agent then updates the records.
|
||||||
|
// If the data is not found or the system is down, it sets the system down.
|
||||||
|
func (sys *System) StartUpdater() {
|
||||||
|
// Channel that can be used to set the system down. Currently only used to
|
||||||
|
// allow a short delay for reconnection after websocket connection is closed.
|
||||||
|
var downChan chan struct{}
|
||||||
|
|
||||||
|
// Add random jitter to first WebSocket connection to prevent
|
||||||
|
// clustering if all agents are started at the same time.
|
||||||
|
// SSH connections during hub startup are already staggered.
|
||||||
|
var jitter <-chan time.Time
|
||||||
|
if sys.WsConn != nil {
|
||||||
|
jitter = getJitter()
|
||||||
|
// use the websocket connection's down channel to set the system down
|
||||||
|
downChan = sys.WsConn.DownChan
|
||||||
|
} else {
|
||||||
|
// if the system does not have a websocket connection, wait before updating
|
||||||
|
// to allow the agent to connect via websocket (makes sure fingerprint is set).
|
||||||
|
time.Sleep(11 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// update immediately if system is not paused (only for ws connections)
|
||||||
|
// we'll wait a minute before connecting via SSH to prioritize ws connections
|
||||||
|
if sys.Status != paused && sys.ctx.Err() == nil {
|
||||||
|
if err := sys.update(); err != nil {
|
||||||
|
_ = sys.setDown(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.updateTicker = time.NewTicker(time.Duration(interval) * time.Millisecond)
|
||||||
|
// Go 1.23+ will automatically stop the ticker when the system is garbage collected, however we seem to need this or testing/synctest will block even if calling runtime.GC()
|
||||||
|
defer sys.updateTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sys.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-sys.updateTicker.C:
|
||||||
|
if err := sys.update(); err != nil {
|
||||||
|
_ = sys.setDown(err)
|
||||||
|
}
|
||||||
|
case <-downChan:
|
||||||
|
sys.WsConn = nil
|
||||||
|
downChan = nil
|
||||||
|
_ = sys.setDown(nil)
|
||||||
|
case <-jitter:
|
||||||
|
sys.updateTicker.Reset(time.Duration(interval) * time.Millisecond)
|
||||||
|
if err := sys.update(); err != nil {
|
||||||
|
_ = sys.setDown(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update updates the system data and records.
|
||||||
|
func (sys *System) update() error {
|
||||||
|
if sys.Status == paused {
|
||||||
|
sys.handlePaused()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := sys.fetchDataFromAgent()
|
||||||
|
if err == nil {
|
||||||
|
_, err = sys.createRecords(data)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sys *System) handlePaused() {
|
||||||
|
if sys.WsConn == nil {
|
||||||
|
// if the system is paused and there's no websocket connection, remove the system
|
||||||
|
_ = sys.manager.RemoveSystem(sys.Id)
|
||||||
|
} else {
|
||||||
|
// Send a ping to the agent to keep the connection alive if the system is paused
|
||||||
|
if err := sys.WsConn.Ping(); err != nil {
|
||||||
|
sys.manager.hub.Logger().Warn("Failed to ping agent", "system", sys.Id, "err", err)
|
||||||
|
_ = sys.manager.RemoveSystem(sys.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRecords updates the system record and adds system_stats and container_stats records
|
||||||
|
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
|
||||||
|
systemRecord, err := sys.getRecord()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hub := sys.manager.hub
|
||||||
|
// add system_stats and container_stats records
|
||||||
|
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
systemStatsRecord := core.NewRecord(systemStatsCollection)
|
||||||
|
systemStatsRecord.Set("system", systemRecord.Id)
|
||||||
|
systemStatsRecord.Set("stats", data.Stats)
|
||||||
|
systemStatsRecord.Set("type", "1m")
|
||||||
|
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// add new container_stats record
|
||||||
|
if len(data.Containers) > 0 {
|
||||||
|
containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
containerStatsRecord := core.NewRecord(containerStatsCollection)
|
||||||
|
containerStatsRecord.Set("system", systemRecord.Id)
|
||||||
|
containerStatsRecord.Set("stats", data.Containers)
|
||||||
|
containerStatsRecord.Set("type", "1m")
|
||||||
|
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
|
systemRecord.Set("status", up)
|
||||||
|
|
||||||
|
systemRecord.Set("info", data.Info)
|
||||||
|
if err := hub.SaveNoValidate(systemRecord); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return systemRecord, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRecord retrieves the system record from the database.
|
||||||
|
// If the record is not found, it removes the system from the manager.
|
||||||
|
func (sys *System) getRecord() (*core.Record, error) {
|
||||||
|
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
|
||||||
|
if err != nil || record == nil {
|
||||||
|
_ = sys.manager.RemoveSystem(sys.Id)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDown marks a system as down in the database.
|
||||||
|
// It takes the original error that caused the system to go down and returns any error
|
||||||
|
// encountered during the process of updating the system status.
|
||||||
|
func (sys *System) setDown(originalError error) error {
|
||||||
|
if sys.Status == down || sys.Status == paused {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
record, err := sys.getRecord()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if originalError != nil {
|
||||||
|
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
|
||||||
|
}
|
||||||
|
record.Set("status", down)
|
||||||
|
return sys.manager.hub.SaveNoValidate(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sys *System) getContext() (context.Context, context.CancelFunc) {
|
||||||
|
if sys.ctx == nil {
|
||||||
|
sys.ctx, sys.cancel = context.WithCancel(context.Background())
|
||||||
|
}
|
||||||
|
return sys.ctx, sys.cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDataFromAgent attempts to fetch data from the agent,
|
||||||
|
// prioritizing WebSocket if available.
|
||||||
|
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
|
||||||
|
if sys.data == nil {
|
||||||
|
sys.data = &system.CombinedData{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||||
|
wsData, err := sys.fetchDataViaWebSocket()
|
||||||
|
if err == nil {
|
||||||
|
return wsData, nil
|
||||||
|
}
|
||||||
|
// close the WebSocket connection if error and try SSH
|
||||||
|
sys.closeWebSocketConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
sshData, err := sys.fetchDataViaSSH()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sshData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sys *System) fetchDataViaWebSocket() (*system.CombinedData, error) {
|
||||||
|
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
||||||
|
return nil, errors.New("no websocket connection")
|
||||||
|
}
|
||||||
|
err := sys.WsConn.RequestSystemData(sys.data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sys.data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDataViaSSH handles fetching data using SSH.
|
||||||
|
// This function encapsulates the original SSH logic.
|
||||||
|
// It updates sys.data directly upon successful fetch.
|
||||||
|
func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) {
|
||||||
|
maxRetries := 1
|
||||||
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
if sys.client == nil || sys.Status == down {
|
||||||
|
if err := sys.createSSHClient(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := sys.createSessionWithTimeout(4 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
if attempt >= maxRetries {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
|
||||||
|
sys.closeSSHConnection()
|
||||||
|
// Reset format detection on connection failure - agent might have been upgraded
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
*sys.data = system.CombinedData{}
|
||||||
|
|
||||||
|
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
||||||
|
err = cbor.NewDecoder(stdout).Decode(sys.data)
|
||||||
|
} else {
|
||||||
|
err = json.NewDecoder(stdout).Decode(sys.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sys.closeSSHConnection()
|
||||||
|
if attempt < maxRetries {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait for the session to complete
|
||||||
|
if err := session.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sys.data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// this should never be reached due to the return in the loop
|
||||||
|
return nil, fmt.Errorf("failed to fetch data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSSHClient creates a new SSH client for the system
|
||||||
|
func (s *System) createSSHClient() error {
|
||||||
|
if s.manager.sshConfig == nil {
|
||||||
|
if err := s.manager.createSSHClientConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
network := "tcp"
|
||||||
|
host := s.Host
|
||||||
|
if strings.HasPrefix(host, "/") {
|
||||||
|
network = "unix"
|
||||||
|
} else {
|
||||||
|
host = net.JoinHostPort(host, s.Port)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
|
||||||
|
// in case of network issues
|
||||||
|
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
||||||
|
if sys.client == nil {
|
||||||
|
return nil, fmt.Errorf("client not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(sys.ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sessionChan := make(chan *ssh.Session, 1)
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if session, err := sys.client.NewSession(); err != nil {
|
||||||
|
errChan <- err
|
||||||
|
} else {
|
||||||
|
sessionChan <- session
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case session := <-sessionChan:
|
||||||
|
return session, nil
|
||||||
|
case err := <-errChan:
|
||||||
|
return nil, err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeSSHConnection closes the SSH connection but keeps the system in the manager
|
||||||
|
func (sys *System) closeSSHConnection() {
|
||||||
|
if sys.client != nil {
|
||||||
|
sys.client.Close()
|
||||||
|
sys.client = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeWebSocketConnection closes the WebSocket connection but keeps the system in the manager
|
||||||
|
// to allow updating via SSH. It will be removed if the WS connection is re-established.
|
||||||
|
// The system will be set as down a few seconds later if the connection is not re-established.
|
||||||
|
func (sys *System) closeWebSocketConnection() {
|
||||||
|
if sys.WsConn != nil {
|
||||||
|
sys.WsConn.Close(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAgentVersion extracts the beszel version from SSH server version string
|
||||||
|
func extractAgentVersion(versionString string) (semver.Version, error) {
|
||||||
|
_, after, _ := strings.Cut(versionString, "_")
|
||||||
|
return semver.Parse(after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJitter returns a channel that will be triggered after a random delay
|
||||||
|
// between 40% and 90% of the interval.
|
||||||
|
// This is used to stagger the initial WebSocket connections to prevent clustering.
|
||||||
|
func getJitter() <-chan time.Time {
|
||||||
|
minPercent := 40
|
||||||
|
maxPercent := 90
|
||||||
|
jitterRange := maxPercent - minPercent
|
||||||
|
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
|
||||||
|
return time.After(time.Duration(msDelay) * time.Millisecond)
|
||||||
|
}
|
||||||
346
beszel/internal/hub/systems/system_manager.go
Normal file
346
beszel/internal/hub/systems/system_manager.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"beszel/internal/hub/ws"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// System status constants
|
||||||
|
const (
|
||||||
|
up string = "up" // System is online and responding
|
||||||
|
down string = "down" // System is offline or not responding
|
||||||
|
paused string = "paused" // System monitoring is paused
|
||||||
|
pending string = "pending" // System is waiting on initial connection result
|
||||||
|
|
||||||
|
// interval is the default update interval in milliseconds (60 seconds)
|
||||||
|
interval int = 60_000
|
||||||
|
// interval int = 10_000 // Debug interval for faster updates
|
||||||
|
|
||||||
|
// sessionTimeout is the maximum time to wait for SSH connections
|
||||||
|
sessionTimeout = 4 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// errSystemExists is returned when attempting to add a system that already exists
|
||||||
|
errSystemExists = errors.New("system exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemManager manages a collection of monitored systems and their connections.
|
||||||
|
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||||
|
type SystemManager struct {
|
||||||
|
hub hubLike // Hub interface for database and alert operations
|
||||||
|
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||||
|
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||||
|
}
|
||||||
|
|
||||||
|
// hubLike defines the interface requirements for the hub dependency.
|
||||||
|
// It extends core.App with system-specific functionality.
|
||||||
|
type hubLike interface {
|
||||||
|
core.App
|
||||||
|
GetSSHKey(dataDir string) (ssh.Signer, error)
|
||||||
|
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
||||||
|
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
||||||
|
// The hub must implement the hubLike interface to provide database and alert functionality.
|
||||||
|
func NewSystemManager(hub hubLike) *SystemManager {
|
||||||
|
return &SystemManager{
|
||||||
|
systems: store.New(map[string]*System{}),
|
||||||
|
hub: hub,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize sets up the system manager by binding event hooks and starting existing systems.
|
||||||
|
// It configures SSH client settings and begins monitoring all non-paused systems from the database.
|
||||||
|
// Systems are started with staggered delays to prevent overwhelming the hub during startup.
|
||||||
|
func (sm *SystemManager) Initialize() error {
|
||||||
|
sm.bindEventHooks()
|
||||||
|
|
||||||
|
// Initialize SSH client configuration
|
||||||
|
err := sm.createSSHClientConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing systems from database (excluding paused ones)
|
||||||
|
var systems []*System
|
||||||
|
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
|
||||||
|
if err != nil || len(systems) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start systems in background with staggered timing
|
||||||
|
go func() {
|
||||||
|
// Calculate staggered delay between system starts (max 2 seconds per system)
|
||||||
|
delta := interval / max(1, len(systems))
|
||||||
|
delta = min(delta, 2_000)
|
||||||
|
sleepTime := time.Duration(delta) * time.Millisecond
|
||||||
|
|
||||||
|
for _, system := range systems {
|
||||||
|
time.Sleep(sleepTime)
|
||||||
|
_ = sm.AddSystem(system)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindEventHooks registers event handlers for system and fingerprint record changes.
|
||||||
|
// These hooks ensure the system manager stays synchronized with database changes.
|
||||||
|
func (sm *SystemManager) bindEventHooks() {
|
||||||
|
sm.hub.OnRecordCreate("systems").BindFunc(sm.onRecordCreate)
|
||||||
|
sm.hub.OnRecordAfterCreateSuccess("systems").BindFunc(sm.onRecordAfterCreateSuccess)
|
||||||
|
sm.hub.OnRecordUpdate("systems").BindFunc(sm.onRecordUpdate)
|
||||||
|
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
|
||||||
|
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
|
||||||
|
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// onTokenRotated handles fingerprint token rotation events.
|
||||||
|
// When a system's authentication token is rotated, any existing WebSocket connection
|
||||||
|
// must be closed to force re-authentication with the new token.
|
||||||
|
func (sm *SystemManager) onTokenRotated(e *core.RecordEvent) error {
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
system, ok := sm.systems.GetOk(systemID)
|
||||||
|
if !ok {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
// No need to close connection if not connected via websocket
|
||||||
|
if system.WsConn == nil {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
system.setDown(nil)
|
||||||
|
sm.RemoveSystem(systemID)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// onRecordCreate is called before a new system record is committed to the database.
|
||||||
|
// It initializes the record with default values: empty info and pending status.
|
||||||
|
func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error {
|
||||||
|
e.Record.Set("info", system.Info{})
|
||||||
|
e.Record.Set("status", pending)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// onRecordAfterCreateSuccess is called after a new system record is successfully created.
|
||||||
|
// It adds the new system to the manager to begin monitoring.
|
||||||
|
func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
|
||||||
|
if err := sm.AddRecord(e.Record, nil); err != nil {
|
||||||
|
e.App.Logger().Error("Error adding record", "err", err)
|
||||||
|
}
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// onRecordUpdate is called before a system record is updated in the database.
|
||||||
|
// It clears system info when the status is changed to paused.
|
||||||
|
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
|
||||||
|
if e.Record.GetString("status") == paused {
|
||||||
|
e.Record.Set("info", system.Info{})
|
||||||
|
}
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// onRecordAfterUpdateSuccess handles system record updates after they're committed to the database.
|
||||||
|
// It manages system lifecycle based on status changes and triggers appropriate alerts.
|
||||||
|
// Status transitions are handled as follows:
|
||||||
|
// - paused: Closes SSH connection and deactivates alerts
|
||||||
|
// - pending: Starts monitoring (reuses WebSocket if available)
|
||||||
|
// - up: Triggers system alerts
|
||||||
|
// - down: Triggers status change alerts
|
||||||
|
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
||||||
|
newStatus := e.Record.GetString("status")
|
||||||
|
prevStatus := pending
|
||||||
|
system, ok := sm.systems.GetOk(e.Record.Id)
|
||||||
|
if ok {
|
||||||
|
prevStatus = system.Status
|
||||||
|
system.Status = newStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
switch newStatus {
|
||||||
|
case paused:
|
||||||
|
if ok {
|
||||||
|
// Pause monitoring but keep system in manager for potential resume
|
||||||
|
system.closeSSHConnection()
|
||||||
|
}
|
||||||
|
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||||
|
return e.Next()
|
||||||
|
case pending:
|
||||||
|
// Resume monitoring, preferring existing WebSocket connection
|
||||||
|
if ok && system.WsConn != nil {
|
||||||
|
go system.update()
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
// Start new monitoring session
|
||||||
|
if err := sm.AddRecord(e.Record, nil); err != nil {
|
||||||
|
e.App.Logger().Error("Error adding record", "err", err)
|
||||||
|
}
|
||||||
|
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle systems not in manager
|
||||||
|
if !ok {
|
||||||
|
return sm.AddRecord(e.Record, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger system alerts when system comes online
|
||||||
|
if newStatus == up {
|
||||||
|
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
|
||||||
|
e.App.Logger().Error("Error handling system alerts", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger status change alerts for up/down transitions
|
||||||
|
if (newStatus == down && prevStatus == up) || (newStatus == up && prevStatus == down) {
|
||||||
|
if err := sm.hub.HandleStatusAlerts(newStatus, e.Record); err != nil {
|
||||||
|
e.App.Logger().Error("Error handling status alerts", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// onRecordAfterDeleteSuccess is called after a system record is successfully deleted.
|
||||||
|
// It removes the system from the manager and cleans up all associated resources.
|
||||||
|
func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
|
||||||
|
sm.RemoveSystem(e.Record.Id)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSystem adds a system to the manager and starts monitoring it.
|
||||||
|
// It validates required fields, initializes the system context, and starts the update goroutine.
|
||||||
|
// Returns error if a system with the same ID already exists.
|
||||||
|
func (sm *SystemManager) AddSystem(sys *System) error {
|
||||||
|
if sm.systems.Has(sys.Id) {
|
||||||
|
return errSystemExists
|
||||||
|
}
|
||||||
|
if sys.Id == "" || sys.Host == "" {
|
||||||
|
return errors.New("system missing required fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize system for monitoring
|
||||||
|
sys.manager = sm
|
||||||
|
sys.ctx, sys.cancel = sys.getContext()
|
||||||
|
sys.data = &system.CombinedData{}
|
||||||
|
sm.systems.Set(sys.Id, sys)
|
||||||
|
|
||||||
|
// Start monitoring in background
|
||||||
|
go sys.StartUpdater()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSystem removes a system from the manager and cleans up all associated resources.
|
||||||
|
// It cancels the system's context, closes all connections, and removes it from the store.
|
||||||
|
// Returns an error if the system is not found.
|
||||||
|
func (sm *SystemManager) RemoveSystem(systemID string) error {
|
||||||
|
system, ok := sm.systems.GetOk(systemID)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("system not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the update goroutine
|
||||||
|
if system.cancel != nil {
|
||||||
|
system.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up all connections
|
||||||
|
system.closeSSHConnection()
|
||||||
|
system.closeWebSocketConnection()
|
||||||
|
sm.systems.Remove(systemID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRecord creates a System instance from a database record and adds it to the manager.
|
||||||
|
// If a system with the same ID already exists, it's removed first to ensure clean state.
|
||||||
|
// If no system instance is provided, a new one is created.
|
||||||
|
// This method is typically called when systems are created or their status changes to pending.
|
||||||
|
func (sm *SystemManager) AddRecord(record *core.Record, system *System) (err error) {
|
||||||
|
// Remove existing system to ensure clean state
|
||||||
|
if sm.systems.Has(record.Id) {
|
||||||
|
_ = sm.RemoveSystem(record.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new system if none provided
|
||||||
|
if system == nil {
|
||||||
|
system = sm.NewSystem(record.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate system from record
|
||||||
|
system.Status = record.GetString("status")
|
||||||
|
system.Host = record.GetString("host")
|
||||||
|
system.Port = record.GetString("port")
|
||||||
|
|
||||||
|
return sm.AddSystem(system)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddWebSocketSystem creates and adds a system with an established WebSocket connection.
|
||||||
|
// This method is called when an agent connects via WebSocket with valid authentication.
|
||||||
|
// The system is immediately added to monitoring with the provided connection and version info.
|
||||||
|
func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver.Version, wsConn *ws.WsConn) error {
|
||||||
|
systemRecord, err := sm.hub.FindRecordById("systems", systemId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
system := sm.NewSystem(systemId)
|
||||||
|
system.WsConn = wsConn
|
||||||
|
system.agentVersion = agentVersion
|
||||||
|
|
||||||
|
if err := sm.AddRecord(systemRecord, system); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||||
|
func (sm *SystemManager) createSSHClientConfig() error {
|
||||||
|
privateKey, err := sm.hub.GetSSHKey("")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.sshConfig = &ssh.ClientConfig{
|
||||||
|
User: "u",
|
||||||
|
Auth: []ssh.AuthMethod{
|
||||||
|
ssh.PublicKeys(privateKey),
|
||||||
|
},
|
||||||
|
Config: ssh.Config{
|
||||||
|
Ciphers: common.DefaultCiphers,
|
||||||
|
KeyExchanges: common.DefaultKeyExchanges,
|
||||||
|
MACs: common.DefaultMACs,
|
||||||
|
},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
ClientVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
|
||||||
|
Timeout: sessionTimeout,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deactivateAlerts finds all triggered alerts for a system and sets them to inactive.
|
||||||
|
// This is called when a system is paused or goes offline to prevent continued alerts.
|
||||||
|
func deactivateAlerts(app core.App, systemID string) error {
|
||||||
|
// Note: Direct SQL updates don't trigger SSE, so we use the PocketBase API
|
||||||
|
// _, err := app.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", systemID)).Execute()
|
||||||
|
|
||||||
|
alerts, err := app.FindRecordsByFilter("alerts", fmt.Sprintf("system = '%s' && triggered = 1", systemID), "", -1, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, alert := range alerts {
|
||||||
|
alert.Set("triggered", false)
|
||||||
|
if err := app.SaveNoValidate(alert); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
package systems
|
|
||||||
|
|
||||||
import (
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/store"
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
up string = "up"
|
|
||||||
down string = "down"
|
|
||||||
paused string = "paused"
|
|
||||||
pending string = "pending"
|
|
||||||
|
|
||||||
interval int = 60_000
|
|
||||||
|
|
||||||
sessionTimeout = 4 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
type SystemManager struct {
|
|
||||||
hub hubLike
|
|
||||||
systems *store.Store[string, *System]
|
|
||||||
sshConfig *ssh.ClientConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
type System struct {
|
|
||||||
Id string `db:"id"`
|
|
||||||
Host string `db:"host"`
|
|
||||||
Port string `db:"port"`
|
|
||||||
Status string `db:"status"`
|
|
||||||
manager *SystemManager
|
|
||||||
client *ssh.Client
|
|
||||||
data *system.CombinedData
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
type hubLike interface {
|
|
||||||
core.App
|
|
||||||
GetSSHKey() ([]byte, error)
|
|
||||||
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
|
||||||
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSystemManager(hub hubLike) *SystemManager {
|
|
||||||
return &SystemManager{
|
|
||||||
systems: store.New(map[string]*System{}),
|
|
||||||
hub: hub,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize initializes the system manager.
|
|
||||||
// It binds the event hooks and starts updating existing systems.
|
|
||||||
func (sm *SystemManager) Initialize() error {
|
|
||||||
sm.bindEventHooks()
|
|
||||||
// ssh setup
|
|
||||||
key, err := sm.hub.GetSSHKey()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := sm.createSSHClientConfig(key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// start updating existing systems
|
|
||||||
var systems []*System
|
|
||||||
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
|
|
||||||
if err != nil || len(systems) == 0 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
// time between initial system updates
|
|
||||||
delta := interval / max(1, len(systems))
|
|
||||||
delta = min(delta, 2_000)
|
|
||||||
sleepTime := time.Duration(delta) * time.Millisecond
|
|
||||||
for _, system := range systems {
|
|
||||||
time.Sleep(sleepTime)
|
|
||||||
_ = sm.AddSystem(system)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SystemManager) bindEventHooks() {
|
|
||||||
sm.hub.OnRecordCreate("systems").BindFunc(sm.onRecordCreate)
|
|
||||||
sm.hub.OnRecordAfterCreateSuccess("systems").BindFunc(sm.onRecordAfterCreateSuccess)
|
|
||||||
sm.hub.OnRecordUpdate("systems").BindFunc(sm.onRecordUpdate)
|
|
||||||
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
|
|
||||||
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs before the record is committed to the database
|
|
||||||
func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error {
|
|
||||||
e.Record.Set("info", system.Info{})
|
|
||||||
e.Record.Set("status", pending)
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs after the record is committed to the database
|
|
||||||
func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
|
|
||||||
if err := sm.AddRecord(e.Record); err != nil {
|
|
||||||
e.App.Logger().Error("Error adding record", "err", err)
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs before the record is updated
|
|
||||||
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
|
|
||||||
if e.Record.GetString("status") == paused {
|
|
||||||
e.Record.Set("info", system.Info{})
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs after the record is updated
|
|
||||||
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|
||||||
newStatus := e.Record.GetString("status")
|
|
||||||
switch newStatus {
|
|
||||||
case paused:
|
|
||||||
sm.RemoveSystem(e.Record.Id)
|
|
||||||
return e.Next()
|
|
||||||
case pending:
|
|
||||||
if err := sm.AddRecord(e.Record); err != nil {
|
|
||||||
e.App.Logger().Error("Error adding record", "err", err)
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
system, ok := sm.systems.GetOk(e.Record.Id)
|
|
||||||
if !ok {
|
|
||||||
return sm.AddRecord(e.Record)
|
|
||||||
}
|
|
||||||
prevStatus := system.Status
|
|
||||||
system.Status = newStatus
|
|
||||||
// system alerts if system is up
|
|
||||||
if system.Status == up {
|
|
||||||
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
|
|
||||||
e.App.Logger().Error("Error handling system alerts", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (system.Status == down && prevStatus == up) || (system.Status == up && prevStatus == down) {
|
|
||||||
if err := sm.hub.HandleStatusAlerts(system.Status, e.Record); err != nil {
|
|
||||||
e.App.Logger().Error("Error handling status alerts", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs after the record is deleted
|
|
||||||
func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
|
|
||||||
sm.RemoveSystem(e.Record.Id)
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddSystem adds a system to the manager
|
|
||||||
func (sm *SystemManager) AddSystem(sys *System) error {
|
|
||||||
if sm.systems.Has(sys.Id) {
|
|
||||||
return fmt.Errorf("system exists")
|
|
||||||
}
|
|
||||||
if sys.Id == "" || sys.Host == "" {
|
|
||||||
return fmt.Errorf("system is missing required fields")
|
|
||||||
}
|
|
||||||
sys.manager = sm
|
|
||||||
sys.ctx, sys.cancel = context.WithCancel(context.Background())
|
|
||||||
sys.data = &system.CombinedData{}
|
|
||||||
sm.systems.Set(sys.Id, sys)
|
|
||||||
go sys.StartUpdater()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveSystem removes a system from the manager
|
|
||||||
func (sm *SystemManager) RemoveSystem(systemID string) error {
|
|
||||||
system, ok := sm.systems.GetOk(systemID)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("system not found")
|
|
||||||
}
|
|
||||||
// cancel the context to signal stop
|
|
||||||
if system.cancel != nil {
|
|
||||||
system.cancel()
|
|
||||||
}
|
|
||||||
system.resetSSHClient()
|
|
||||||
sm.systems.Remove(systemID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddRecord adds a record to the system manager.
|
|
||||||
// It first removes any existing system with the same ID, then creates a new System
|
|
||||||
// instance from the record data and adds it to the manager.
|
|
||||||
// This function is typically called when a new system is created or when an existing
|
|
||||||
// system's status changes to pending.
|
|
||||||
func (sm *SystemManager) AddRecord(record *core.Record) (err error) {
|
|
||||||
_ = sm.RemoveSystem(record.Id)
|
|
||||||
system := &System{
|
|
||||||
Id: record.Id,
|
|
||||||
Status: record.GetString("status"),
|
|
||||||
Host: record.GetString("host"),
|
|
||||||
Port: record.GetString("port"),
|
|
||||||
}
|
|
||||||
return sm.AddSystem(system)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartUpdater starts the system updater.
|
|
||||||
// It first fetches the data from the agent then updates the records.
|
|
||||||
// If the data is not found or the system is down, it sets the system down.
|
|
||||||
func (sys *System) StartUpdater() {
|
|
||||||
if sys.data == nil {
|
|
||||||
sys.data = &system.CombinedData{}
|
|
||||||
}
|
|
||||||
if err := sys.update(); err != nil {
|
|
||||||
_ = sys.setDown(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := time.Tick(time.Duration(interval) * time.Millisecond)
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-sys.ctx.Done():
|
|
||||||
return
|
|
||||||
case <-c:
|
|
||||||
err := sys.update()
|
|
||||||
if err != nil {
|
|
||||||
_ = sys.setDown(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update updates the system data and records.
|
|
||||||
// It first fetches the data from the agent then updates the records.
|
|
||||||
func (sys *System) update() error {
|
|
||||||
_, err := sys.fetchDataFromAgent()
|
|
||||||
if err == nil {
|
|
||||||
_, err = sys.createRecords()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// createRecords updates the system record and adds system_stats and container_stats records
|
|
||||||
func (sys *System) createRecords() (*core.Record, error) {
|
|
||||||
systemRecord, err := sys.getRecord()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
hub := sys.manager.hub
|
|
||||||
// add system_stats and container_stats records
|
|
||||||
systemStats, err := hub.FindCachedCollectionByNameOrId("system_stats")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
systemStatsRecord := core.NewRecord(systemStats)
|
|
||||||
systemStatsRecord.Set("system", systemRecord.Id)
|
|
||||||
systemStatsRecord.Set("stats", sys.data.Stats)
|
|
||||||
systemStatsRecord.Set("type", "1m")
|
|
||||||
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// add new container_stats record
|
|
||||||
if len(sys.data.Containers) > 0 {
|
|
||||||
containerStats, err := hub.FindCachedCollectionByNameOrId("container_stats")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
containerStatsRecord := core.NewRecord(containerStats)
|
|
||||||
containerStatsRecord.Set("system", systemRecord.Id)
|
|
||||||
containerStatsRecord.Set("stats", sys.data.Containers)
|
|
||||||
containerStatsRecord.Set("type", "1m")
|
|
||||||
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
|
||||||
systemRecord.Set("status", up)
|
|
||||||
systemRecord.Set("info", sys.data.Info)
|
|
||||||
if err := hub.SaveNoValidate(systemRecord); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return systemRecord, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRecord retrieves the system record from the database.
|
|
||||||
// If the record is not found or the system is paused, it removes the system from the manager.
|
|
||||||
func (sys *System) getRecord() (*core.Record, error) {
|
|
||||||
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
|
|
||||||
if err != nil || record == nil {
|
|
||||||
_ = sys.manager.RemoveSystem(sys.Id)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setDown marks a system as down in the database.
|
|
||||||
// It takes the original error that caused the system to go down and returns any error
|
|
||||||
// encountered during the process of updating the system status.
|
|
||||||
func (sys *System) setDown(OriginalError error) error {
|
|
||||||
if sys.Status == down {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
record, err := sys.getRecord()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", OriginalError)
|
|
||||||
record.Set("status", down)
|
|
||||||
err = sys.manager.hub.SaveNoValidate(record)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchDataFromAgent fetches the data from the agent.
|
|
||||||
// It first creates a new SSH client if it doesn't exist or the system is down.
|
|
||||||
// Then it creates a new SSH session and fetches the data from the agent.
|
|
||||||
// If the data is not found or the system is down, it sets the system down.
|
|
||||||
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
|
|
||||||
maxRetries := 1
|
|
||||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
||||||
if sys.client == nil || sys.Status == down {
|
|
||||||
if err := sys.createSSHClient(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := sys.createSessionWithTimeout(4 * time.Second)
|
|
||||||
if err != nil {
|
|
||||||
if attempt >= maxRetries {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
|
|
||||||
sys.resetSSHClient()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
|
|
||||||
stdout, err := session.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := session.Shell(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is initialized in startUpdater, should never be nil
|
|
||||||
*sys.data = system.CombinedData{}
|
|
||||||
if err := json.NewDecoder(stdout).Decode(sys.data); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// wait for the session to complete
|
|
||||||
if err := session.Wait(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return sys.data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// this should never be reached due to the return in the loop
|
|
||||||
return nil, fmt.Errorf("failed to fetch data")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SystemManager) createSSHClientConfig(key []byte) error {
|
|
||||||
signer, err := ssh.ParsePrivateKey(key)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sm.sshConfig = &ssh.ClientConfig{
|
|
||||||
User: "u",
|
|
||||||
Auth: []ssh.AuthMethod{
|
|
||||||
ssh.PublicKeys(signer),
|
|
||||||
},
|
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
||||||
Timeout: sessionTimeout,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createSSHClient creates a new SSH client for the system
|
|
||||||
func (s *System) createSSHClient() error {
|
|
||||||
network := "tcp"
|
|
||||||
host := s.Host
|
|
||||||
if strings.HasPrefix(host, "/") {
|
|
||||||
network = "unix"
|
|
||||||
} else {
|
|
||||||
host = net.JoinHostPort(host, s.Port)
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
|
|
||||||
// in case of network issues
|
|
||||||
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
|
||||||
if sys.client == nil {
|
|
||||||
return nil, fmt.Errorf("client not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(sys.ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
sessionChan := make(chan *ssh.Session, 1)
|
|
||||||
errChan := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if session, err := sys.client.NewSession(); err != nil {
|
|
||||||
errChan <- err
|
|
||||||
} else {
|
|
||||||
sessionChan <- session
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case session := <-sessionChan:
|
|
||||||
return session, nil
|
|
||||||
case err := <-errChan:
|
|
||||||
return nil, err
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, fmt.Errorf("timeout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetSSHClient closes the SSH connection and resets the client to nil
|
|
||||||
func (sys *System) resetSSHClient() {
|
|
||||||
if sys.client != nil {
|
|
||||||
sys.client.Close()
|
|
||||||
}
|
|
||||||
sys.client = nil
|
|
||||||
}
|
|
||||||
@@ -11,70 +11,135 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// createTestSystem creates a test system record with a unique host name
|
func TestSystemManagerNew(t *testing.T) {
|
||||||
// and returns the created record and any error
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
func createTestSystem(t *testing.T, hub *tests.TestHub, options map[string]any) (*core.Record, error) {
|
|
||||||
collection, err := hub.FindCachedCollectionByNameOrId("systems")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// get user record
|
|
||||||
var firstUser *core.Record
|
|
||||||
users, err := hub.FindAllRecords("users", dbx.NewExp("id != ''"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(users) > 0 {
|
|
||||||
firstUser = users[0]
|
|
||||||
}
|
|
||||||
// Generate a unique host name to ensure we're adding a new system
|
|
||||||
uniqueHost := fmt.Sprintf("test-host-%d.example.com", time.Now().UnixNano())
|
|
||||||
|
|
||||||
// Create the record
|
|
||||||
record := core.NewRecord(collection)
|
|
||||||
record.Set("name", uniqueHost)
|
|
||||||
record.Set("host", uniqueHost)
|
|
||||||
record.Set("port", "45876")
|
|
||||||
record.Set("status", "pending")
|
|
||||||
record.Set("users", []string{firstUser.Id})
|
|
||||||
|
|
||||||
// Apply any custom options
|
|
||||||
for key, value := range options {
|
|
||||||
record.Set(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the record to the database
|
|
||||||
err = hub.Save(record)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemManagerIntegration(t *testing.T) {
|
|
||||||
// Create a test hub
|
|
||||||
hub, err := tests.NewTestHub()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
sm := hub.GetSystemManager()
|
||||||
|
|
||||||
// Create independent system manager
|
user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
|
||||||
sm := systems.NewSystemManager(hub)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
sm.Initialize()
|
||||||
|
|
||||||
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "it-was-coney-island",
|
||||||
|
"host": "the-playground-of-the-world",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "pending", record.GetString("status"), "System status should be 'pending'")
|
||||||
|
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
|
||||||
|
|
||||||
|
// Verify the system host and port
|
||||||
|
host, port := sm.GetSystemHostPort(record.Id)
|
||||||
|
assert.Equal(t, record.GetString("host"), host, "System host should match")
|
||||||
|
assert.Equal(t, record.GetString("port"), port, "System port should match")
|
||||||
|
|
||||||
|
time.Sleep(13 * time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, "pending", record.Fresh().GetString("status"), "System status should be 'pending'")
|
||||||
|
// Verify the system was added by checking if it exists
|
||||||
|
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
// system should be set to down after 15 seconds (no websocket connection)
|
||||||
|
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
|
||||||
|
// make sure the system is down in the db
|
||||||
|
record, err = hub.FindRecordById("systems", record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "down", record.GetString("status"), "System status should be 'down'")
|
||||||
|
|
||||||
|
assert.Equal(t, 1, sm.GetSystemCount(), "System count should be 1")
|
||||||
|
|
||||||
|
err = sm.RemoveSystem(record.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0")
|
||||||
|
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
|
||||||
|
|
||||||
|
// let's also make sure a system is removed from the store when the record is deleted
|
||||||
|
record, err = tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "there-was-no-place-like-it",
|
||||||
|
"host": "in-the-whole-world",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store after creation")
|
||||||
|
|
||||||
|
time.Sleep(8 * time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
|
||||||
|
|
||||||
|
sm.SetSystemStatusInDB(record.Id, "up")
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'")
|
||||||
|
|
||||||
|
// make sure the system switches to down after 11 seconds
|
||||||
|
sm.RemoveSystem(record.Id)
|
||||||
|
sm.AddRecord(record, nil)
|
||||||
|
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
|
||||||
|
time.Sleep(12 * time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
|
||||||
|
|
||||||
|
// sm.SetSystemStatusInDB(record.Id, "paused")
|
||||||
|
// time.Sleep(time.Second)
|
||||||
|
// synctest.Wait()
|
||||||
|
// assert.Equal(t, "paused", sm.GetSystemStatusFromStore(record.Id), "System status should be 'paused'")
|
||||||
|
|
||||||
|
// delete the record
|
||||||
|
err = hub.Delete(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
|
||||||
|
})
|
||||||
|
|
||||||
|
testOld(t, hub)
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
for _, systemId := range sm.GetAllSystemIDs() {
|
||||||
|
err = sm.RemoveSystem(systemId)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, sm.HasSystem(systemId), "System should not exist in the store after deletion")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0")
|
||||||
|
|
||||||
|
// TODO: test with websocket client
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOld(t *testing.T, hub *tests.TestHub) {
|
||||||
|
user, err := tests.CreateUser(hub, "test@testy.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sm := hub.GetSystemManager()
|
||||||
assert.NotNil(t, sm)
|
assert.NotNil(t, sm)
|
||||||
|
|
||||||
// Test initialization
|
// error expected when creating a user with a duplicate email
|
||||||
sm.Initialize()
|
_, err = tests.CreateUser(hub, "test@test.com", "testtesttest")
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
// Test collection existence. todo: move to hub package tests
|
// Test collection existence. todo: move to hub package tests
|
||||||
t.Run("CollectionExistence", func(t *testing.T) {
|
t.Run("CollectionExistence", func(t *testing.T) {
|
||||||
@@ -92,81 +157,17 @@ func TestSystemManagerIntegration(t *testing.T) {
|
|||||||
assert.NotNil(t, containerStats)
|
assert.NotNil(t, containerStats)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test adding a system record
|
|
||||||
t.Run("AddRecord", func(t *testing.T) {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
// Get the count before adding the system
|
|
||||||
countBefore := sm.GetSystemCount()
|
|
||||||
|
|
||||||
// record should be pending on create
|
|
||||||
hub.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
record := e.Record
|
|
||||||
if record.GetString("name") == "welcometoarcoampm" {
|
|
||||||
assert.Equal(t, "pending", e.Record.GetString("status"), "System status should be 'pending'")
|
|
||||||
wg.Done()
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// record should be down on update
|
|
||||||
hub.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
record := e.Record
|
|
||||||
if record.GetString("name") == "welcometoarcoampm" {
|
|
||||||
assert.Equal(t, "down", e.Record.GetString("status"), "System status should be 'pending'")
|
|
||||||
wg.Done()
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
// Create a test system with the first user assigned
|
|
||||||
record, err := createTestSystem(t, hub, map[string]any{
|
|
||||||
"name": "welcometoarcoampm",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "33914",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// system should be down if grabbed from the store
|
|
||||||
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
|
|
||||||
|
|
||||||
// Check that the system count increased
|
|
||||||
countAfter := sm.GetSystemCount()
|
|
||||||
assert.Equal(t, countBefore+1, countAfter, "System count should increase after adding a system via event hook")
|
|
||||||
|
|
||||||
// Verify the system was added by checking if it exists
|
|
||||||
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
|
|
||||||
|
|
||||||
// Verify the system host and port
|
|
||||||
host, port := sm.GetSystemHostPort(record.Id)
|
|
||||||
assert.Equal(t, record.Get("host"), host, "System host should match")
|
|
||||||
assert.Equal(t, record.Get("port"), port, "System port should match")
|
|
||||||
|
|
||||||
// Verify the system is in the list of all system IDs
|
|
||||||
ids := sm.GetAllSystemIDs()
|
|
||||||
assert.Contains(t, ids, record.Id, "System ID should be in the list of all system IDs")
|
|
||||||
|
|
||||||
// Verify the system was added by checking if removing it works
|
|
||||||
err = sm.RemoveSystem(record.Id)
|
|
||||||
assert.NoError(t, err, "System should exist and be removable")
|
|
||||||
|
|
||||||
// Verify the system no longer exists
|
|
||||||
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
|
|
||||||
|
|
||||||
// Verify the system is not in the list of all system IDs
|
|
||||||
newIds := sm.GetAllSystemIDs()
|
|
||||||
assert.NotContains(t, newIds, record.Id, "System ID should not be in the list of all system IDs after removal")
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("RemoveSystem", func(t *testing.T) {
|
t.Run("RemoveSystem", func(t *testing.T) {
|
||||||
// Get the count before adding the system
|
// Get the count before adding the system
|
||||||
countBefore := sm.GetSystemCount()
|
countBefore := sm.GetSystemCount()
|
||||||
|
|
||||||
// Create a test system record
|
// Create a test system record
|
||||||
record, err := createTestSystem(t, hub, map[string]any{})
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "i-even-got-lost-at-coney-island",
|
||||||
|
"host": "but-they-found-me",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify the system count increased
|
// Verify the system count increased
|
||||||
@@ -202,11 +203,16 @@ func TestSystemManagerIntegration(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("NewRecordPending", func(t *testing.T) {
|
t.Run("NewRecordPending", func(t *testing.T) {
|
||||||
// Create a test system
|
// Create a test system
|
||||||
record, err := createTestSystem(t, hub, map[string]any{})
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "and-you-know",
|
||||||
|
"host": "i-feel-very-bad",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add the record to the system manager
|
// Add the record to the system manager
|
||||||
err = sm.AddRecord(record)
|
err = sm.AddRecord(record, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Test filtering records by status - should be "pending" now
|
// Test filtering records by status - should be "pending" now
|
||||||
@@ -218,11 +224,16 @@ func TestSystemManagerIntegration(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("SystemStatusUpdate", func(t *testing.T) {
|
t.Run("SystemStatusUpdate", func(t *testing.T) {
|
||||||
// Create a test system record
|
// Create a test system record
|
||||||
record, err := createTestSystem(t, hub, map[string]any{})
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "we-used-to-sleep-on-the-beach",
|
||||||
|
"host": "sleep-overnight-here",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add the record to the system manager
|
// Add the record to the system manager
|
||||||
err = sm.AddRecord(record)
|
err = sm.AddRecord(record, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Test status changes
|
// Test status changes
|
||||||
@@ -244,7 +255,12 @@ func TestSystemManagerIntegration(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("HandleSystemData", func(t *testing.T) {
|
t.Run("HandleSystemData", func(t *testing.T) {
|
||||||
// Create a test system record
|
// Create a test system record
|
||||||
record, err := createTestSystem(t, hub, map[string]any{})
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "things-changed-you-know",
|
||||||
|
"host": "they-dont-sleep-anymore-on-the-beach",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create test system data
|
// Create test system data
|
||||||
@@ -295,54 +311,14 @@ func TestSystemManagerIntegration(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("DeleteRecord", func(t *testing.T) {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
runs := 0
|
|
||||||
|
|
||||||
hub.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
runs++
|
|
||||||
record := e.Record
|
|
||||||
if record.GetString("name") == "deadflagblues" {
|
|
||||||
if runs == 1 {
|
|
||||||
assert.Equal(t, "up", e.Record.GetString("status"), "System status should be 'up'")
|
|
||||||
wg.Done()
|
|
||||||
} else if runs == 2 {
|
|
||||||
assert.Equal(t, "paused", e.Record.GetString("status"), "System status should be 'paused'")
|
|
||||||
wg.Done()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a test system record
|
|
||||||
record, err := createTestSystem(t, hub, map[string]any{
|
|
||||||
"name": "deadflagblues",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify the system exists
|
|
||||||
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
|
|
||||||
|
|
||||||
// set the status manually to up
|
|
||||||
sm.SetSystemStatusInDB(record.Id, "up")
|
|
||||||
|
|
||||||
// verify the status is up
|
|
||||||
assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'")
|
|
||||||
|
|
||||||
// Set the status to "paused" which should cause it to be deleted from the store
|
|
||||||
sm.SetSystemStatusInDB(record.Id, "paused")
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// Verify the system no longer exists
|
|
||||||
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ConcurrentOperations", func(t *testing.T) {
|
t.Run("ConcurrentOperations", func(t *testing.T) {
|
||||||
// Create a test system
|
// Create a test system
|
||||||
record, err := createTestSystem(t, hub, map[string]any{})
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "jfkjahkfajs",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Run concurrent operations
|
// Run concurrent operations
|
||||||
@@ -377,7 +353,12 @@ func TestSystemManagerIntegration(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("ContextCancellation", func(t *testing.T) {
|
t.Run("ContextCancellation", func(t *testing.T) {
|
||||||
// Create a test system record
|
// Create a test system record
|
||||||
record, err := createTestSystem(t, hub, map[string]any{})
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "lkhsdfsjf",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify the system exists in the store
|
// Verify the system exists in the store
|
||||||
@@ -420,7 +401,7 @@ func TestSystemManagerIntegration(t *testing.T) {
|
|||||||
assert.Error(t, err, "RemoveSystem should fail for non-existent system")
|
assert.Error(t, err, "RemoveSystem should fail for non-existent system")
|
||||||
|
|
||||||
// Add the system back
|
// Add the system back
|
||||||
err = sm.AddRecord(record)
|
err = sm.AddRecord(record, nil)
|
||||||
require.NoError(t, err, "AddRecord should succeed")
|
require.NoError(t, err, "AddRecord should succeed")
|
||||||
|
|
||||||
// Verify the system is back in the store
|
// Verify the system is back in the store
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSystemCount returns the number of systems in the store
|
// TESTING ONLY: GetSystemCount returns the number of systems in the store
|
||||||
func (sm *SystemManager) GetSystemCount() int {
|
func (sm *SystemManager) GetSystemCount() int {
|
||||||
return sm.systems.Length()
|
return sm.systems.Length()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasSystem checks if a system with the given ID exists in the store
|
// TESTING ONLY: HasSystem checks if a system with the given ID exists in the store
|
||||||
func (sm *SystemManager) HasSystem(systemID string) bool {
|
func (sm *SystemManager) HasSystem(systemID string) bool {
|
||||||
return sm.systems.Has(systemID)
|
return sm.systems.Has(systemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystemStatusFromStore returns the status of a system with the given ID
|
// TESTING ONLY: GetSystemStatusFromStore returns the status of a system with the given ID
|
||||||
// Returns an empty string if the system doesn't exist
|
// Returns an empty string if the system doesn't exist
|
||||||
func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
|
func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
|
||||||
sys, ok := sm.systems.GetOk(systemID)
|
sys, ok := sm.systems.GetOk(systemID)
|
||||||
@@ -29,7 +29,7 @@ func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
|
|||||||
return sys.Status
|
return sys.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystemContextFromStore returns the context and cancel function for a system
|
// TESTING ONLY: GetSystemContextFromStore returns the context and cancel function for a system
|
||||||
func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Context, context.CancelFunc, error) {
|
func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Context, context.CancelFunc, error) {
|
||||||
sys, ok := sm.systems.GetOk(systemID)
|
sys, ok := sm.systems.GetOk(systemID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -38,7 +38,7 @@ func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Con
|
|||||||
return sys.ctx, sys.cancel, nil
|
return sys.ctx, sys.cancel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystemFromStore returns a store from the system
|
// TESTING ONLY: GetSystemFromStore returns a store from the system
|
||||||
func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
|
func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
|
||||||
sys, ok := sm.systems.GetOk(systemID)
|
sys, ok := sm.systems.GetOk(systemID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -47,7 +47,7 @@ func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
|
|||||||
return sys, nil
|
return sys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllSystemIDs returns a slice of all system IDs in the store
|
// TESTING ONLY: GetAllSystemIDs returns a slice of all system IDs in the store
|
||||||
func (sm *SystemManager) GetAllSystemIDs() []string {
|
func (sm *SystemManager) GetAllSystemIDs() []string {
|
||||||
data := sm.systems.GetAll()
|
data := sm.systems.GetAll()
|
||||||
ids := make([]string, 0, len(data))
|
ids := make([]string, 0, len(data))
|
||||||
@@ -57,7 +57,7 @@ func (sm *SystemManager) GetAllSystemIDs() []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystemData returns the combined data for a system with the given ID
|
// TESTING ONLY: GetSystemData returns the combined data for a system with the given ID
|
||||||
// Returns nil if the system doesn't exist
|
// Returns nil if the system doesn't exist
|
||||||
// This method is intended for testing
|
// This method is intended for testing
|
||||||
func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
|
func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
|
||||||
@@ -68,7 +68,7 @@ func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
|
|||||||
return sys.data
|
return sys.data
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystemHostPort returns the host and port for a system with the given ID
|
// TESTING ONLY: GetSystemHostPort returns the host and port for a system with the given ID
|
||||||
// Returns empty strings if the system doesn't exist
|
// Returns empty strings if the system doesn't exist
|
||||||
func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
|
func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
|
||||||
sys, ok := sm.systems.GetOk(systemID)
|
sys, ok := sm.systems.GetOk(systemID)
|
||||||
@@ -78,22 +78,7 @@ func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
|
|||||||
return sys.Host, sys.Port
|
return sys.Host, sys.Port
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisableAutoUpdater disables the automatic updater for a system
|
// TESTING ONLY: SetSystemStatusInDB sets the status of a system directly and updates the database record
|
||||||
// This is intended for testing
|
|
||||||
// Returns false if the system doesn't exist
|
|
||||||
// func (sm *SystemManager) DisableAutoUpdater(systemID string) bool {
|
|
||||||
// sys, ok := sm.systems.GetOk(systemID)
|
|
||||||
// if !ok {
|
|
||||||
// return false
|
|
||||||
// }
|
|
||||||
// if sys.cancel != nil {
|
|
||||||
// sys.cancel()
|
|
||||||
// sys.cancel = nil
|
|
||||||
// }
|
|
||||||
// return true
|
|
||||||
// }
|
|
||||||
|
|
||||||
// SetSystemStatusInDB sets the status of a system directly and updates the database record
|
|
||||||
// This is intended for testing
|
// This is intended for testing
|
||||||
// Returns false if the system doesn't exist
|
// Returns false if the system doesn't exist
|
||||||
func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) bool {
|
func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) bool {
|
||||||
|
|||||||
@@ -1,57 +1,85 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel/internal/ghupdate"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"os/exec"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
|
||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update updates beszel to the latest version
|
// Update updates beszel to the latest version
|
||||||
func Update(_ *cobra.Command, _ []string) {
|
func Update(cmd *cobra.Command, _ []string) {
|
||||||
var latest *selfupdate.Release
|
dataDir := os.TempDir()
|
||||||
var found bool
|
|
||||||
var err error
|
// set dataDir to ./beszel_data if it exists
|
||||||
currentVersion := semver.MustParse(beszel.Version)
|
if _, err := os.Stat("./beszel_data"); err == nil {
|
||||||
fmt.Println("beszel", currentVersion)
|
dataDir = "./beszel_data"
|
||||||
fmt.Println("Checking for updates...")
|
}
|
||||||
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
|
||||||
Filters: []string{"beszel_"},
|
// Check if china-mirrors flag is set
|
||||||
|
useMirror, _ := cmd.Flags().GetBool("china-mirrors")
|
||||||
|
|
||||||
|
updated, err := ghupdate.Update(ghupdate.Config{
|
||||||
|
ArchiveExecutable: "beszel",
|
||||||
|
DataDir: dataDir,
|
||||||
|
UseMirror: useMirror,
|
||||||
})
|
})
|
||||||
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error checking for updates:", err)
|
log.Fatal(err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
if !updated {
|
||||||
if !found {
|
|
||||||
fmt.Println("No updates found")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Latest version:", latest.Version)
|
|
||||||
|
|
||||||
if latest.Version.LTE(currentVersion) {
|
|
||||||
fmt.Println("You are up to date")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var binaryPath string
|
// make sure the file is executable
|
||||||
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
exePath, err := os.Executable()
|
||||||
binaryPath, err = os.Executable()
|
if err == nil {
|
||||||
if err != nil {
|
if err := os.Chmod(exePath, 0755); err != nil {
|
||||||
fmt.Println("Error getting binary path:", err)
|
fmt.Printf("Warning: failed to set executable permissions: %v\n", err)
|
||||||
os.Exit(1)
|
}
|
||||||
}
|
}
|
||||||
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
|
||||||
if err != nil {
|
// Try to restart the service if it's running
|
||||||
fmt.Println("Please try rerunning with sudo. Error:", err)
|
restartService()
|
||||||
os.Exit(1)
|
}
|
||||||
}
|
|
||||||
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
// restartService attempts to restart the beszel service
|
||||||
|
func restartService() {
|
||||||
|
// Check if we're running as a service by looking for systemd
|
||||||
|
if _, err := exec.LookPath("systemctl"); err == nil {
|
||||||
|
// Check if beszel service exists and is active
|
||||||
|
cmd := exec.Command("systemctl", "is-active", "beszel.service")
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
||||||
|
restartCmd := exec.Command("systemctl", "restart", "beszel.service")
|
||||||
|
if err := restartCmd.Run(); err != nil {
|
||||||
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo systemctl restart beszel")
|
||||||
|
} else {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for OpenRC (Alpine Linux)
|
||||||
|
if _, err := exec.LookPath("rc-service"); err == nil {
|
||||||
|
cmd := exec.Command("rc-service", "beszel", "status")
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
||||||
|
restartCmd := exec.Command("rc-service", "beszel", "restart")
|
||||||
|
if err := restartCmd.Run(); err != nil {
|
||||||
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo rc-service beszel restart")
|
||||||
|
} else {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Service restart not attempted. If running as a service, restart manually.")
|
||||||
}
|
}
|
||||||
|
|||||||
180
beszel/internal/hub/ws/ws.go
Normal file
180
beszel/internal/hub/ws/ws.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
"weak"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
deadline = 70 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler implements the WebSocket event handler for agent connections.
|
||||||
|
type Handler struct {
|
||||||
|
gws.BuiltinEventHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// WsConn represents a WebSocket connection to an agent.
|
||||||
|
type WsConn struct {
|
||||||
|
conn *gws.Conn
|
||||||
|
responseChan chan *gws.Message
|
||||||
|
DownChan chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FingerprintRecord is fingerprints collection record data in the hub
|
||||||
|
type FingerprintRecord struct {
|
||||||
|
Id string `db:"id"`
|
||||||
|
SystemId string `db:"system"`
|
||||||
|
Fingerprint string `db:"fingerprint"`
|
||||||
|
Token string `db:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var upgrader *gws.Upgrader
|
||||||
|
|
||||||
|
// GetUpgrader returns a singleton WebSocket upgrader instance.
|
||||||
|
func GetUpgrader() *gws.Upgrader {
|
||||||
|
if upgrader != nil {
|
||||||
|
return upgrader
|
||||||
|
}
|
||||||
|
handler := &Handler{}
|
||||||
|
upgrader = gws.NewUpgrader(handler, &gws.ServerOption{})
|
||||||
|
return upgrader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWsConnection creates a new WebSocket connection wrapper.
|
||||||
|
func NewWsConnection(conn *gws.Conn) *WsConn {
|
||||||
|
return &WsConn{
|
||||||
|
conn: conn,
|
||||||
|
responseChan: make(chan *gws.Message, 1),
|
||||||
|
DownChan: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnOpen sets a deadline for the WebSocket connection.
|
||||||
|
func (h *Handler) OnOpen(conn *gws.Conn) {
|
||||||
|
conn.SetDeadline(time.Now().Add(deadline))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage routes incoming WebSocket messages to the response channel.
|
||||||
|
func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
|
||||||
|
conn.SetDeadline(time.Now().Add(deadline))
|
||||||
|
if message.Opcode != gws.OpcodeBinary || message.Data.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wsConn, ok := conn.Session().Load("wsConn")
|
||||||
|
if !ok {
|
||||||
|
_ = conn.WriteClose(1000, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case wsConn.(*WsConn).responseChan <- message:
|
||||||
|
default:
|
||||||
|
// close if the connection is not expecting a response
|
||||||
|
wsConn.(*WsConn).Close(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnClose handles WebSocket connection closures and triggers system down status after delay.
|
||||||
|
func (h *Handler) OnClose(conn *gws.Conn, err error) {
|
||||||
|
wsConn, ok := conn.Session().Load("wsConn")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wsConn.(*WsConn).conn = nil
|
||||||
|
// wait 5 seconds to allow reconnection before setting system down
|
||||||
|
// use a weak pointer to avoid keeping references if the system is removed
|
||||||
|
go func(downChan weak.Pointer[chan struct{}]) {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
downChanValue := downChan.Value()
|
||||||
|
if downChanValue != nil {
|
||||||
|
*downChanValue <- struct{}{}
|
||||||
|
}
|
||||||
|
}(weak.Make(&wsConn.(*WsConn).DownChan))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the WebSocket connection gracefully.
|
||||||
|
func (ws *WsConn) Close(msg []byte) {
|
||||||
|
if ws.IsConnected() {
|
||||||
|
ws.conn.WriteClose(1000, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping sends a ping frame to keep the connection alive.
|
||||||
|
func (ws *WsConn) Ping() error {
|
||||||
|
ws.conn.SetDeadline(time.Now().Add(deadline))
|
||||||
|
return ws.conn.WritePing(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
||||||
|
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
||||||
|
if ws.conn == nil {
|
||||||
|
return gws.ErrConnClosed
|
||||||
|
}
|
||||||
|
bytes, err := cbor.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ws.conn.WriteMessage(gws.OpcodeBinary, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSystemData requests system metrics from the agent and unmarshals the response.
|
||||||
|
func (ws *WsConn) RequestSystemData(data *system.CombinedData) error {
|
||||||
|
var message *gws.Message
|
||||||
|
|
||||||
|
ws.sendMessage(common.HubRequest[any]{
|
||||||
|
Action: common.GetData,
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
ws.Close(nil)
|
||||||
|
return gws.ErrConnClosed
|
||||||
|
case message = <-ws.responseChan:
|
||||||
|
}
|
||||||
|
defer message.Close()
|
||||||
|
return cbor.Unmarshal(message.Data.Bytes(), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
|
||||||
|
func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
|
||||||
|
var clientFingerprint common.FingerprintResponse
|
||||||
|
challenge := []byte(token)
|
||||||
|
|
||||||
|
signature, err := signer.Sign(nil, challenge)
|
||||||
|
if err != nil {
|
||||||
|
return clientFingerprint, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ws.sendMessage(common.HubRequest[any]{
|
||||||
|
Action: common.CheckFingerprint,
|
||||||
|
Data: common.FingerprintRequest{
|
||||||
|
Signature: signature.Blob,
|
||||||
|
NeedSysInfo: needSysInfo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return clientFingerprint, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var message *gws.Message
|
||||||
|
select {
|
||||||
|
case message = <-ws.responseChan:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
return clientFingerprint, errors.New("request expired")
|
||||||
|
}
|
||||||
|
defer message.Close()
|
||||||
|
|
||||||
|
err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint)
|
||||||
|
return clientFingerprint, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns true if the WebSocket connection is active.
|
||||||
|
func (ws *WsConn) IsConnected() bool {
|
||||||
|
return ws.conn != nil
|
||||||
|
}
|
||||||
221
beszel/internal/hub/ws/ws_test.go
Normal file
221
beszel/internal/hub/ws/ws_test.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetUpgrader tests the singleton upgrader
|
||||||
|
func TestGetUpgrader(t *testing.T) {
|
||||||
|
// Reset the global upgrader to test singleton behavior
|
||||||
|
upgrader = nil
|
||||||
|
|
||||||
|
// First call should create the upgrader
|
||||||
|
upgrader1 := GetUpgrader()
|
||||||
|
assert.NotNil(t, upgrader1, "Upgrader should not be nil")
|
||||||
|
|
||||||
|
// Second call should return the same instance
|
||||||
|
upgrader2 := GetUpgrader()
|
||||||
|
assert.Same(t, upgrader1, upgrader2, "Should return the same upgrader instance")
|
||||||
|
|
||||||
|
// Verify it's properly configured
|
||||||
|
assert.NotNil(t, upgrader1, "Upgrader should be configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWsConnection tests WebSocket connection creation
|
||||||
|
func TestNewWsConnection(t *testing.T) {
|
||||||
|
// We can't easily mock gws.Conn, so we'll pass nil and test the structure
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
assert.NotNil(t, wsConn, "WebSocket connection should not be nil")
|
||||||
|
assert.Nil(t, wsConn.conn, "Connection should be nil as passed")
|
||||||
|
assert.NotNil(t, wsConn.responseChan, "Response channel should be initialized")
|
||||||
|
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
|
||||||
|
assert.Equal(t, 1, cap(wsConn.responseChan), "Response channel should have capacity of 1")
|
||||||
|
assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_IsConnected tests the connection status check
|
||||||
|
func TestWsConn_IsConnected(t *testing.T) {
|
||||||
|
// Test with nil connection
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_Close tests the connection closing with nil connection
|
||||||
|
func TestWsConn_Close(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
// Should handle nil connection gracefully
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
wsConn.Close([]byte("test message"))
|
||||||
|
}, "Should not panic when closing nil connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
|
||||||
|
func TestWsConn_SendMessage_CBOR(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
testData := common.HubRequest[any]{
|
||||||
|
Action: common.GetData,
|
||||||
|
Data: "test data",
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will fail because conn is nil, but we can test the CBOR encoding logic
|
||||||
|
// by checking that the function properly encodes to CBOR before failing
|
||||||
|
err := wsConn.sendMessage(testData)
|
||||||
|
assert.Error(t, err, "Should error with nil connection")
|
||||||
|
|
||||||
|
// Test CBOR encoding separately
|
||||||
|
bytes, err := cbor.Marshal(testData)
|
||||||
|
assert.NoError(t, err, "Should encode to CBOR successfully")
|
||||||
|
|
||||||
|
// Verify we can decode it back
|
||||||
|
var decodedData common.HubRequest[any]
|
||||||
|
err = cbor.Unmarshal(bytes, &decodedData)
|
||||||
|
assert.NoError(t, err, "Should decode from CBOR successfully")
|
||||||
|
assert.Equal(t, testData.Action, decodedData.Action, "Action should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_GetFingerprint_SignatureGeneration tests signature creation logic
|
||||||
|
func TestWsConn_GetFingerprint_SignatureGeneration(t *testing.T) {
|
||||||
|
// Generate test key pair
|
||||||
|
_, privKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
signer, err := ssh.NewSignerFromKey(privKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
token := "test-token"
|
||||||
|
|
||||||
|
// This will timeout since conn is nil, but we can verify the signature logic
|
||||||
|
// We can't test the full flow, but we can test that the signature is created properly
|
||||||
|
challenge := []byte(token)
|
||||||
|
signature, err := signer.Sign(nil, challenge)
|
||||||
|
assert.NoError(t, err, "Should create signature successfully")
|
||||||
|
assert.NotEmpty(t, signature.Blob, "Signature blob should not be empty")
|
||||||
|
assert.Equal(t, signer.PublicKey().Type(), signature.Format, "Signature format should match key type")
|
||||||
|
|
||||||
|
// Test the fingerprint request structure
|
||||||
|
fpRequest := common.FingerprintRequest{
|
||||||
|
Signature: signature.Blob,
|
||||||
|
NeedSysInfo: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test CBOR encoding of fingerprint request
|
||||||
|
fpData, err := cbor.Marshal(fpRequest)
|
||||||
|
assert.NoError(t, err, "Should encode fingerprint request to CBOR")
|
||||||
|
|
||||||
|
var decodedFpRequest common.FingerprintRequest
|
||||||
|
err = cbor.Unmarshal(fpData, &decodedFpRequest)
|
||||||
|
assert.NoError(t, err, "Should decode fingerprint request from CBOR")
|
||||||
|
assert.Equal(t, fpRequest.Signature, decodedFpRequest.Signature, "Signature should match")
|
||||||
|
assert.Equal(t, fpRequest.NeedSysInfo, decodedFpRequest.NeedSysInfo, "NeedSysInfo should match")
|
||||||
|
|
||||||
|
// Test the full hub request structure
|
||||||
|
hubRequest := common.HubRequest[any]{
|
||||||
|
Action: common.CheckFingerprint,
|
||||||
|
Data: fpRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
hubData, err := cbor.Marshal(hubRequest)
|
||||||
|
assert.NoError(t, err, "Should encode hub request to CBOR")
|
||||||
|
|
||||||
|
var decodedHubRequest common.HubRequest[cbor.RawMessage]
|
||||||
|
err = cbor.Unmarshal(hubData, &decodedHubRequest)
|
||||||
|
assert.NoError(t, err, "Should decode hub request from CBOR")
|
||||||
|
assert.Equal(t, common.CheckFingerprint, decodedHubRequest.Action, "Action should be CheckFingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_RequestSystemData_RequestFormat tests system data request format
|
||||||
|
func TestWsConn_RequestSystemData_RequestFormat(t *testing.T) {
|
||||||
|
// Test the request format that would be sent
|
||||||
|
request := common.HubRequest[any]{
|
||||||
|
Action: common.GetData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test CBOR encoding
|
||||||
|
data, err := cbor.Marshal(request)
|
||||||
|
assert.NoError(t, err, "Should encode request to CBOR")
|
||||||
|
|
||||||
|
// Test decoding
|
||||||
|
var decodedRequest common.HubRequest[any]
|
||||||
|
err = cbor.Unmarshal(data, &decodedRequest)
|
||||||
|
assert.NoError(t, err, "Should decode request from CBOR")
|
||||||
|
assert.Equal(t, common.GetData, decodedRequest.Action, "Should have GetData action")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFingerprintRecord tests the FingerprintRecord struct
|
||||||
|
func TestFingerprintRecord(t *testing.T) {
|
||||||
|
record := FingerprintRecord{
|
||||||
|
Id: "test-id",
|
||||||
|
SystemId: "system-123",
|
||||||
|
Fingerprint: "test-fingerprint",
|
||||||
|
Token: "test-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "test-id", record.Id)
|
||||||
|
assert.Equal(t, "system-123", record.SystemId)
|
||||||
|
assert.Equal(t, "test-fingerprint", record.Fingerprint)
|
||||||
|
assert.Equal(t, "test-token", record.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeadlineConstant tests that the deadline constant is reasonable
|
||||||
|
func TestDeadlineConstant(t *testing.T) {
|
||||||
|
assert.Equal(t, 70*time.Second, deadline, "Deadline should be 70 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommonActions tests that the common actions are properly defined
|
||||||
|
func TestCommonActions(t *testing.T) {
|
||||||
|
// Test that the actions we use exist and have expected values
|
||||||
|
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
|
||||||
|
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler tests that we can create a Handler
|
||||||
|
func TestHandler(t *testing.T) {
|
||||||
|
handler := &Handler{}
|
||||||
|
assert.NotNil(t, handler, "Handler should be created successfully")
|
||||||
|
|
||||||
|
// The Handler embeds gws.BuiltinEventHandler, so it should have the embedded type
|
||||||
|
assert.NotNil(t, handler.BuiltinEventHandler, "Should have embedded BuiltinEventHandler")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections
|
||||||
|
func TestWsConnChannelBehavior(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
// Test that channels are properly initialized and can be used
|
||||||
|
select {
|
||||||
|
case wsConn.DownChan <- struct{}{}:
|
||||||
|
// Should be able to write to channel
|
||||||
|
default:
|
||||||
|
t.Error("Should be able to write to DownChan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test reading from DownChan
|
||||||
|
select {
|
||||||
|
case <-wsConn.DownChan:
|
||||||
|
// Should be able to read from channel
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
t.Error("Should be able to read from DownChan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response channel should be empty initially
|
||||||
|
select {
|
||||||
|
case <-wsConn.responseChan:
|
||||||
|
t.Error("Response channel should be empty initially")
|
||||||
|
default:
|
||||||
|
// Expected - channel should be empty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ package records
|
|||||||
import (
|
import (
|
||||||
"beszel/internal/entities/container"
|
"beszel/internal/entities/container"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
@@ -26,14 +26,26 @@ type LongerRecordData struct {
|
|||||||
minShorterRecords int
|
minShorterRecords int
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecordStats []struct {
|
type RecordIds []struct {
|
||||||
Stats []byte `db:"stats"`
|
Id string `db:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRecordManager(app core.App) *RecordManager {
|
func NewRecordManager(app core.App) *RecordManager {
|
||||||
return &RecordManager{app}
|
return &RecordManager{app}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatsRecord struct {
|
||||||
|
Stats []byte `db:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// global variables for reusing allocations
|
||||||
|
var statsRecord StatsRecord
|
||||||
|
var containerStats []container.Stats
|
||||||
|
var sumStats system.Stats
|
||||||
|
var tempStats system.Stats
|
||||||
|
var queryParams = make(dbx.Params, 1)
|
||||||
|
var containerSums = make(map[string]*container.Stats)
|
||||||
|
|
||||||
// Create longer records by averaging shorter records
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords() {
|
func (rm *RecordManager) CreateLongerRecords() {
|
||||||
// start := time.Now()
|
// start := time.Now()
|
||||||
@@ -76,11 +88,10 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var systems []struct {
|
var systems RecordIds
|
||||||
Id string `db:"id"`
|
db := txApp.DB()
|
||||||
}
|
|
||||||
|
|
||||||
txApp.DB().NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
|
db.NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
|
||||||
|
|
||||||
// loop through all active systems, time periods, and collections
|
// loop through all active systems, time periods, and collections
|
||||||
for _, system := range systems {
|
for _, system := range systems {
|
||||||
@@ -96,22 +107,23 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// check creation time of last longer record if not 10m, since 10m is created every run
|
// check creation time of last longer record if not 10m, since 10m is created every run
|
||||||
if recordData.longerType != "10m" {
|
if recordData.longerType != "10m" {
|
||||||
lastLongerRecord, err := txApp.FindFirstRecordByFilter(
|
count, err := txApp.CountRecords(
|
||||||
collection.Id,
|
collection.Id,
|
||||||
"system = {:system} && type = {:type} && created > {:created}",
|
dbx.NewExp(
|
||||||
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
"system = {:system} AND type = {:type} AND created > {:created}",
|
||||||
|
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
// continue if longer record exists
|
// continue if longer record exists
|
||||||
if err == nil || lastLongerRecord != nil {
|
if err != nil || count > 0 {
|
||||||
// log.Println("longer record found. continuing")
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// get shorter records from the past x minutes
|
// get shorter records from the past x minutes
|
||||||
var stats RecordStats
|
var recordIds RecordIds
|
||||||
|
|
||||||
err := txApp.DB().
|
err := txApp.DB().
|
||||||
Select("stats").
|
Select("id").
|
||||||
From(collection.Name).
|
From(collection.Name).
|
||||||
AndWhere(dbx.NewExp(
|
AndWhere(dbx.NewExp(
|
||||||
"system={:system} AND type={:type} AND created > {:created}",
|
"system={:system} AND type={:type} AND created > {:created}",
|
||||||
@@ -121,10 +133,10 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
"created": shorterRecordPeriod,
|
"created": shorterRecordPeriod,
|
||||||
},
|
},
|
||||||
)).
|
)).
|
||||||
All(&stats)
|
All(&recordIds)
|
||||||
|
|
||||||
// continue if not enough shorter records
|
// continue if not enough shorter records
|
||||||
if err != nil || len(stats) < recordData.minShorterRecords {
|
if err != nil || len(recordIds) < recordData.minShorterRecords {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// average the shorter records and create longer record
|
// average the shorter records and create longer record
|
||||||
@@ -133,9 +145,10 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
longerRecord.Set("type", recordData.longerType)
|
longerRecord.Set("type", recordData.longerType)
|
||||||
switch collection.Name {
|
switch collection.Name {
|
||||||
case "system_stats":
|
case "system_stats":
|
||||||
longerRecord.Set("stats", rm.AverageSystemStats(stats))
|
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||||
case "container_stats":
|
case "container_stats":
|
||||||
longerRecord.Set("stats", rm.AverageContainerStats(stats))
|
|
||||||
|
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||||
}
|
}
|
||||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||||
log.Println("failed to save longer record", "err", err)
|
log.Println("failed to save longer record", "err", err)
|
||||||
@@ -147,24 +160,36 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
statsRecord.Stats = statsRecord.Stats[:0]
|
||||||
|
|
||||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of system_stats records without reflect
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
|
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
|
||||||
sum := &system.Stats{}
|
// Clear/reset global structs for reuse
|
||||||
|
sumStats = system.Stats{}
|
||||||
|
tempStats = system.Stats{}
|
||||||
|
sum := &sumStats
|
||||||
|
stats := &tempStats
|
||||||
|
// necessary because uint8 is not big enough for the sum
|
||||||
|
batterySum := 0
|
||||||
|
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
|
|
||||||
// Temporary struct for unmarshaling
|
|
||||||
stats := &system.Stats{}
|
|
||||||
|
|
||||||
// Accumulate totals
|
// Accumulate totals
|
||||||
for i := range records {
|
for _, record := range records {
|
||||||
*stats = system.Stats{} // Reset tempStats for unmarshaling
|
id := record.Id
|
||||||
if err := json.Unmarshal(records[i].Stats, stats); err != nil {
|
// clear global statsRecord for reuse
|
||||||
|
statsRecord.Stats = statsRecord.Stats[:0]
|
||||||
|
|
||||||
|
queryParams["id"] = id
|
||||||
|
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
||||||
|
if err := json.Unmarshal(statsRecord.Stats, stats); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sum.Cpu += stats.Cpu
|
sum.Cpu += stats.Cpu
|
||||||
sum.Mem += stats.Mem
|
sum.Mem += stats.Mem
|
||||||
sum.MemUsed += stats.MemUsed
|
sum.MemUsed += stats.MemUsed
|
||||||
@@ -180,12 +205,22 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
|
|||||||
sum.DiskWritePs += stats.DiskWritePs
|
sum.DiskWritePs += stats.DiskWritePs
|
||||||
sum.NetworkSent += stats.NetworkSent
|
sum.NetworkSent += stats.NetworkSent
|
||||||
sum.NetworkRecv += stats.NetworkRecv
|
sum.NetworkRecv += stats.NetworkRecv
|
||||||
|
sum.LoadAvg[0] += stats.LoadAvg[0]
|
||||||
|
sum.LoadAvg[1] += stats.LoadAvg[1]
|
||||||
|
sum.LoadAvg[2] += stats.LoadAvg[2]
|
||||||
|
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||||
|
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||||
|
batterySum += int(stats.Battery[0])
|
||||||
|
sum.Battery[1] = stats.Battery[1]
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
|
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
||||||
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
||||||
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||||
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||||
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
||||||
|
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
||||||
|
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
||||||
|
|
||||||
// Accumulate temperatures
|
// Accumulate temperatures
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
@@ -255,7 +290,12 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
|
|||||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||||
|
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||||
|
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||||
|
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
|
sum.Battery[0] = uint8(batterySum / int(count))
|
||||||
// Average temperatures
|
// Average temperatures
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
for key := range sum.Temperatures {
|
for key := range sum.Temperatures {
|
||||||
@@ -293,14 +333,24 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of container_stats records
|
// Calculate the average stats of a list of container_stats records
|
||||||
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats {
|
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||||
sums := make(map[string]*container.Stats)
|
// Clear global map for reuse
|
||||||
|
for k := range containerSums {
|
||||||
|
delete(containerSums, k)
|
||||||
|
}
|
||||||
|
sums := containerSums
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
containerStats := make([]container.Stats, 0, 50)
|
|
||||||
for i := range records {
|
for i := range records {
|
||||||
// reset slice
|
id := records[i].Id
|
||||||
|
// clear global statsRecord and containerStats for reuse
|
||||||
|
statsRecord.Stats = statsRecord.Stats[:0]
|
||||||
containerStats = containerStats[:0]
|
containerStats = containerStats[:0]
|
||||||
if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil {
|
|
||||||
|
queryParams["id"] = id
|
||||||
|
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
|
||||||
return []container.Stats{}
|
return []container.Stats{}
|
||||||
}
|
}
|
||||||
for i := range containerStats {
|
for i := range containerStats {
|
||||||
@@ -328,12 +378,46 @@ func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletes records older than what is displayed in the UI
|
// Delete old records
|
||||||
func (rm *RecordManager) DeleteOldRecords() {
|
func (rm *RecordManager) DeleteOldRecords() {
|
||||||
// Define the collections to process
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
collections := []string{"system_stats", "container_stats"}
|
err := deleteOldSystemStats(txApp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Define record types and their retention periods
|
// Delete old alerts history records
|
||||||
|
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||||
|
db := app.DB()
|
||||||
|
var users []struct {
|
||||||
|
Id string `db:"user"`
|
||||||
|
}
|
||||||
|
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes system_stats records older than what is displayed in the UI
|
||||||
|
func deleteOldSystemStats(app core.App) error {
|
||||||
|
// Collections to process
|
||||||
|
collections := [2]string{"system_stats", "container_stats"}
|
||||||
|
|
||||||
|
// Record types and their retention periods
|
||||||
type RecordDeletionData struct {
|
type RecordDeletionData struct {
|
||||||
recordType string
|
recordType string
|
||||||
retention time.Duration
|
retention time.Duration
|
||||||
@@ -346,31 +430,28 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
|
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each collection
|
now := time.Now().UTC()
|
||||||
|
|
||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// Build the WHERE clause dynamically
|
// Build the WHERE clause
|
||||||
var conditionParts []string
|
var conditionParts []string
|
||||||
var params dbx.Params = make(map[string]any)
|
var params dbx.Params = make(map[string]any)
|
||||||
|
for i := range recordData {
|
||||||
for i, rd := range recordData {
|
rd := recordData[i]
|
||||||
// Create parameterized condition for this record type
|
// Create parameterized condition for this record type
|
||||||
dateParam := fmt.Sprintf("date%d", i)
|
dateParam := fmt.Sprintf("date%d", i)
|
||||||
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
||||||
params[dateParam] = time.Now().UTC().Add(-rd.retention)
|
params[dateParam] = now.Add(-rd.retention)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine conditions with OR
|
// Combine conditions with OR
|
||||||
conditionStr := strings.Join(conditionParts, " OR ")
|
conditionStr := strings.Join(conditionParts, " OR ")
|
||||||
|
// Construct and execute the full raw query
|
||||||
// Construct the full raw query
|
|
||||||
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
||||||
|
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
||||||
// Execute the query with parameters
|
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||||
if _, err := rm.app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
|
||||||
// return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
|
||||||
rm.app.Logger().Error("failed to delete", "collection", collection, "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
|
|||||||
381
beszel/internal/records/records_test.go
Normal file
381
beszel/internal/records/records_test.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package records_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/records"
|
||||||
|
"beszel/internal/tests"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteOldRecords tests the main DeleteOldRecords function
|
||||||
|
func TestDeleteOldRecords(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
|
||||||
|
// Create test user for alerts history
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create test system
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Create old system_stats records that should be deleted
|
||||||
|
var record *core.Record
|
||||||
|
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"cpu": 50.0, "mem": 1024}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// created is autodate field, so we need to set it manually
|
||||||
|
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, record)
|
||||||
|
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
|
||||||
|
require.Equal(t, record.Get("system"), system.Id)
|
||||||
|
require.Equal(t, record.Get("type"), "1m")
|
||||||
|
|
||||||
|
// Create recent system_stats record that should be kept
|
||||||
|
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"cpu": 30.0, "mem": 512}`,
|
||||||
|
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create many alerts history records to trigger deletion
|
||||||
|
for i := range 260 { // More than countBeforeDeletion (250)
|
||||||
|
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count records before deletion
|
||||||
|
systemStatsCountBefore, err := hub.CountRecords("system_stats")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertsCountBefore, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
rm.DeleteOldRecords()
|
||||||
|
|
||||||
|
// Count records after deletion
|
||||||
|
systemStatsCountAfter, err := hub.CountRecords("system_stats")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertsCountAfter, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify old system stats were deleted
|
||||||
|
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
|
||||||
|
|
||||||
|
// Verify alerts history was trimmed
|
||||||
|
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
|
||||||
|
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
|
||||||
|
func TestDeleteOldSystemStats(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create test system
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Test data for different record types and their retention periods
|
||||||
|
testCases := []struct {
|
||||||
|
recordType string
|
||||||
|
retention time.Duration
|
||||||
|
shouldBeKept bool
|
||||||
|
ageFromNow time.Duration
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
|
||||||
|
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
|
||||||
|
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
|
||||||
|
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
|
||||||
|
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
|
||||||
|
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
|
||||||
|
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
|
||||||
|
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
|
||||||
|
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
|
||||||
|
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test records for both system_stats and container_stats
|
||||||
|
collections := []string{"system_stats", "container_stats"}
|
||||||
|
recordIds := make(map[string][]string)
|
||||||
|
|
||||||
|
for _, collection := range collections {
|
||||||
|
recordIds[collection] = make([]string, 0)
|
||||||
|
|
||||||
|
for i, tc := range testCases {
|
||||||
|
recordTime := now.Add(-tc.ageFromNow)
|
||||||
|
|
||||||
|
var stats string
|
||||||
|
if collection == "system_stats" {
|
||||||
|
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
|
||||||
|
} else {
|
||||||
|
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := tests.CreateRecord(hub, collection, map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": tc.recordType,
|
||||||
|
"stats": stats,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
recordIds[collection] = append(recordIds[collection], record.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
err = records.TestDeleteOldSystemStats(hub)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
for _, collection := range collections {
|
||||||
|
for i, tc := range testCases {
|
||||||
|
recordId := recordIds[collection][i]
|
||||||
|
|
||||||
|
// Try to find the record
|
||||||
|
_, err := hub.FindRecordById(collection, recordId)
|
||||||
|
|
||||||
|
if tc.shouldBeKept {
|
||||||
|
assert.NoError(t, err, "Record should exist: %s", tc.description)
|
||||||
|
} else {
|
||||||
|
assert.Error(t, err, "Record should be deleted: %s", tc.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
|
||||||
|
func TestDeleteOldAlertsHistory(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
user *core.Record
|
||||||
|
alertCount int
|
||||||
|
countToKeep int
|
||||||
|
countBeforeDeletion int
|
||||||
|
expectedAfterDeletion int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "User with few alerts (below threshold)",
|
||||||
|
user: user1,
|
||||||
|
alertCount: 100,
|
||||||
|
countToKeep: 50,
|
||||||
|
countBeforeDeletion: 150,
|
||||||
|
expectedAfterDeletion: 100, // No deletion because below threshold
|
||||||
|
description: "User with alerts below countBeforeDeletion should not have any deleted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "User with many alerts (above threshold)",
|
||||||
|
user: user2,
|
||||||
|
alertCount: 300,
|
||||||
|
countToKeep: 100,
|
||||||
|
countBeforeDeletion: 200,
|
||||||
|
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
|
||||||
|
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create alerts for this user
|
||||||
|
for i := 0; i < tc.alertCount; i++ {
|
||||||
|
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": tc.user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count before deletion
|
||||||
|
countBefore, err := hub.CountRecords("alerts_history",
|
||||||
|
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
err = records.TestDeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Count after deletion
|
||||||
|
countAfter, err := hub.CountRecords("alerts_history",
|
||||||
|
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
|
||||||
|
|
||||||
|
// If deletion occurred, verify the most recent records were kept
|
||||||
|
if tc.expectedAfterDeletion < tc.alertCount {
|
||||||
|
records, err := hub.FindRecordsByFilter("alerts_history",
|
||||||
|
"user = {:user}",
|
||||||
|
"-created", // Order by created DESC
|
||||||
|
tc.countToKeep,
|
||||||
|
0,
|
||||||
|
map[string]any{"user": tc.user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
|
||||||
|
|
||||||
|
// Verify records are in descending order by created time
|
||||||
|
for i := 1; i < len(records); i++ {
|
||||||
|
prev := records[i-1].GetDateTime("created").Time()
|
||||||
|
curr := records[i].GetDateTime("created").Time()
|
||||||
|
assert.True(t, prev.After(curr) || prev.Equal(curr),
|
||||||
|
"Records should be ordered by created time (newest first)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
|
||||||
|
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
t.Run("No users with excessive alerts", func(t *testing.T) {
|
||||||
|
// Create user with few alerts
|
||||||
|
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create only 5 alerts (well below threshold)
|
||||||
|
for i := range 5 {
|
||||||
|
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not error and should not delete anything
|
||||||
|
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
count, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(5), count, "All alerts should remain")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Empty alerts_history table", func(t *testing.T) {
|
||||||
|
// Clear any existing alerts
|
||||||
|
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should not error with empty table
|
||||||
|
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordManagerCreation tests RecordManager creation
|
||||||
|
func TestRecordManagerCreation(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
assert.NotNil(t, rm, "RecordManager should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoDecimals tests the twoDecimals helper function
|
||||||
|
func TestTwoDecimals(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
input float64
|
||||||
|
expected float64
|
||||||
|
}{
|
||||||
|
{1.234567, 1.23},
|
||||||
|
{1.235, 1.24}, // Should round up
|
||||||
|
{1.0, 1.0},
|
||||||
|
{0.0, 0.0},
|
||||||
|
{-1.234567, -1.23},
|
||||||
|
{-1.235, -1.23}, // Negative rounding
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
result := records.TestTwoDecimals(tc.input)
|
||||||
|
assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
beszel/internal/records/records_test_helpers.go
Normal file
23
beszel/internal/records/records_test_helpers.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package records
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteOldSystemStats exposes deleteOldSystemStats for testing
|
||||||
|
func TestDeleteOldSystemStats(app core.App) error {
|
||||||
|
return deleteOldSystemStats(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistory exposes deleteOldAlertsHistory for testing
|
||||||
|
func TestDeleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||||
|
return deleteOldAlertsHistory(app, countToKeep, countBeforeDeletion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoDecimals exposes twoDecimals for testing
|
||||||
|
func TestTwoDecimals(value float64) float64 {
|
||||||
|
return twoDecimals(value)
|
||||||
|
}
|
||||||
309
beszel/internal/tests/api.go
Normal file
309
beszel/internal/tests/api.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTE: This is a copy of https://github.com/pocketbase/pocketbase/blob/master/tests/api.go
|
||||||
|
// with the following changes:
|
||||||
|
// - Removed automatic cleanup of the test app in ApiScenario.Test (Aug 17 2025)
|
||||||
|
|
||||||
|
// ApiScenario defines a single api request test case/scenario.
|
||||||
|
type ApiScenario struct {
|
||||||
|
// Name is the test name.
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Method is the HTTP method of the test request to use.
|
||||||
|
Method string
|
||||||
|
|
||||||
|
// URL is the url/path of the endpoint you want to test.
|
||||||
|
URL string
|
||||||
|
|
||||||
|
// Body specifies the body to send with the request.
|
||||||
|
//
|
||||||
|
// For example:
|
||||||
|
//
|
||||||
|
// strings.NewReader(`{"title":"abc"}`)
|
||||||
|
Body io.Reader
|
||||||
|
|
||||||
|
// Headers specifies the headers to send with the request (e.g. "Authorization": "abc")
|
||||||
|
Headers map[string]string
|
||||||
|
|
||||||
|
// Delay adds a delay before checking the expectations usually
|
||||||
|
// to ensure that all fired non-awaited go routines have finished
|
||||||
|
Delay time.Duration
|
||||||
|
|
||||||
|
// Timeout specifies how long to wait before cancelling the request context.
|
||||||
|
//
|
||||||
|
// A zero or negative value means that there will be no timeout.
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
|
// expectations
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// ExpectedStatus specifies the expected response HTTP status code.
|
||||||
|
ExpectedStatus int
|
||||||
|
|
||||||
|
// List of keywords that MUST exist in the response body.
|
||||||
|
//
|
||||||
|
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
||||||
|
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
||||||
|
ExpectedContent []string
|
||||||
|
|
||||||
|
// List of keywords that MUST NOT exist in the response body.
|
||||||
|
//
|
||||||
|
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
||||||
|
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
||||||
|
NotExpectedContent []string
|
||||||
|
|
||||||
|
// List of hook events to check whether they were fired or not.
|
||||||
|
//
|
||||||
|
// You can use the wildcard "*" event key if you want to ensure
|
||||||
|
// that no other hook events except those listed have been fired.
|
||||||
|
//
|
||||||
|
// For example:
|
||||||
|
//
|
||||||
|
// map[string]int{ "*": 0 } // no hook events were fired
|
||||||
|
// map[string]int{ "*": 0, "EventA": 2 } // no hook events, except EventA were fired
|
||||||
|
// map[string]int{ "EventA": 2, "EventB": 0 } // ensures that EventA was fired exactly 2 times and EventB exactly 0 times.
|
||||||
|
ExpectedEvents map[string]int
|
||||||
|
|
||||||
|
// test hooks
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
TestAppFactory func(t testing.TB) *pbtests.TestApp
|
||||||
|
BeforeTestFunc func(t testing.TB, app *pbtests.TestApp, e *core.ServeEvent)
|
||||||
|
AfterTestFunc func(t testing.TB, app *pbtests.TestApp, res *http.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test executes the test scenario.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func TestListExample(t *testing.T) {
|
||||||
|
// scenario := tests.ApiScenario{
|
||||||
|
// Name: "list example collection",
|
||||||
|
// Method: http.MethodGet,
|
||||||
|
// URL: "/api/collections/example/records",
|
||||||
|
// ExpectedStatus: 200,
|
||||||
|
// ExpectedContent: []string{
|
||||||
|
// `"totalItems":3`,
|
||||||
|
// `"id":"0yxhwia2amd8gec"`,
|
||||||
|
// `"id":"achvryl401bhse3"`,
|
||||||
|
// `"id":"llvuca81nly1qls"`,
|
||||||
|
// },
|
||||||
|
// ExpectedEvents: map[string]int{
|
||||||
|
// "OnRecordsListRequest": 1,
|
||||||
|
// "OnRecordEnrich": 3,
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// scenario.Test(t)
|
||||||
|
// }
|
||||||
|
func (scenario *ApiScenario) Test(t *testing.T) {
|
||||||
|
t.Run(scenario.normalizedName(), func(t *testing.T) {
|
||||||
|
scenario.test(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark benchmarks the test scenario.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func BenchmarkListExample(b *testing.B) {
|
||||||
|
// scenario := tests.ApiScenario{
|
||||||
|
// Name: "list example collection",
|
||||||
|
// Method: http.MethodGet,
|
||||||
|
// URL: "/api/collections/example/records",
|
||||||
|
// ExpectedStatus: 200,
|
||||||
|
// ExpectedContent: []string{
|
||||||
|
// `"totalItems":3`,
|
||||||
|
// `"id":"0yxhwia2amd8gec"`,
|
||||||
|
// `"id":"achvryl401bhse3"`,
|
||||||
|
// `"id":"llvuca81nly1qls"`,
|
||||||
|
// },
|
||||||
|
// ExpectedEvents: map[string]int{
|
||||||
|
// "OnRecordsListRequest": 1,
|
||||||
|
// "OnRecordEnrich": 3,
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// scenario.Benchmark(b)
|
||||||
|
// }
|
||||||
|
func (scenario *ApiScenario) Benchmark(b *testing.B) {
|
||||||
|
b.Run(scenario.normalizedName(), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
scenario.test(b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scenario *ApiScenario) normalizedName() string {
|
||||||
|
var name = scenario.Name
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("%s:%s", scenario.Method, scenario.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scenario *ApiScenario) test(t testing.TB) {
|
||||||
|
var testApp *pbtests.TestApp
|
||||||
|
if scenario.TestAppFactory != nil {
|
||||||
|
testApp = scenario.TestAppFactory(t)
|
||||||
|
if testApp == nil {
|
||||||
|
t.Fatal("TestAppFactory must return a non-nill app instance")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var testAppErr error
|
||||||
|
testApp, testAppErr = pbtests.NewTestApp()
|
||||||
|
if testAppErr != nil {
|
||||||
|
t.Fatalf("Failed to initialize the test app instance: %v", testAppErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// defer testApp.Cleanup()
|
||||||
|
|
||||||
|
baseRouter, err := apis.NewRouter(testApp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// manually trigger the serve event to ensure that custom app routes and middlewares are registered
|
||||||
|
serveEvent := new(core.ServeEvent)
|
||||||
|
serveEvent.App = testApp
|
||||||
|
serveEvent.Router = baseRouter
|
||||||
|
|
||||||
|
serveErr := testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||||
|
if scenario.BeforeTestFunc != nil {
|
||||||
|
scenario.BeforeTestFunc(t, testApp, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset the event counters in case a hook was triggered from a before func (eg. db save)
|
||||||
|
testApp.ResetEventCalls()
|
||||||
|
|
||||||
|
// add middleware to timeout long-running requests (eg. keep-alive routes)
|
||||||
|
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
|
||||||
|
Func: func(re *core.RequestEvent) error {
|
||||||
|
slowTimer := time.AfterFunc(3*time.Second, func() {
|
||||||
|
t.Logf("[WARN] Long running test %q", scenario.Name)
|
||||||
|
})
|
||||||
|
defer slowTimer.Stop()
|
||||||
|
|
||||||
|
if scenario.Timeout > 0 {
|
||||||
|
ctx, cancelFunc := context.WithTimeout(re.Request.Context(), scenario.Timeout)
|
||||||
|
defer cancelFunc()
|
||||||
|
re.Request = re.Request.Clone(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return re.Next()
|
||||||
|
},
|
||||||
|
Priority: -9999,
|
||||||
|
})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(scenario.Method, scenario.URL, scenario.Body)
|
||||||
|
|
||||||
|
// set default header
|
||||||
|
req.Header.Set("content-type", "application/json")
|
||||||
|
|
||||||
|
// set scenario headers
|
||||||
|
for k, v := range scenario.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute request
|
||||||
|
mux, err := e.Router.BuildMux()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to build router mux: %v", err)
|
||||||
|
}
|
||||||
|
mux.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
res := recorder.Result()
|
||||||
|
|
||||||
|
if res.StatusCode != scenario.ExpectedStatus {
|
||||||
|
t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scenario.Delay > 0 {
|
||||||
|
time.Sleep(scenario.Delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
|
||||||
|
if len(recorder.Body.Bytes()) != 0 {
|
||||||
|
t.Errorf("Expected empty body, got \n%v", recorder.Body.String())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// normalize json response format
|
||||||
|
buffer := new(bytes.Buffer)
|
||||||
|
err := json.Compact(buffer, recorder.Body.Bytes())
|
||||||
|
var normalizedBody string
|
||||||
|
if err != nil {
|
||||||
|
// not a json...
|
||||||
|
normalizedBody = recorder.Body.String()
|
||||||
|
} else {
|
||||||
|
normalizedBody = buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range scenario.ExpectedContent {
|
||||||
|
if !strings.Contains(normalizedBody, item) {
|
||||||
|
t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range scenario.NotExpectedContent {
|
||||||
|
if strings.Contains(normalizedBody, item) {
|
||||||
|
t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingEvents := maps.Clone(testApp.EventCalls)
|
||||||
|
|
||||||
|
var noOtherEventsShouldRemain bool
|
||||||
|
for event, expectedNum := range scenario.ExpectedEvents {
|
||||||
|
if event == "*" && expectedNum <= 0 {
|
||||||
|
noOtherEventsShouldRemain = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
actualNum := remainingEvents[event]
|
||||||
|
if actualNum != expectedNum {
|
||||||
|
t.Errorf("Expected event %s to be called %d, got %d", event, expectedNum, actualNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(remainingEvents, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if noOtherEventsShouldRemain && len(remainingEvents) > 0 {
|
||||||
|
t.Errorf("Missing expected remaining events:\n%#v\nAll triggered app events are:\n%#v", remainingEvents, testApp.EventCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scenario.AfterTestFunc != nil {
|
||||||
|
scenario.AfterTestFunc(t, testApp, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if serveErr != nil {
|
||||||
|
t.Fatalf("Failed to trigger app serve hook: %v", serveErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
// Package tests provides helpers for testing the application.
|
// Package tests provides helpers for testing the application.
|
||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/hub"
|
"beszel/internal/hub"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
_ "github.com/pocketbase/pocketbase/migrations"
|
_ "github.com/pocketbase/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
@@ -56,3 +62,37 @@ func NewTestHubWithConfig(config core.BaseAppConfig) (*TestHub, error) {
|
|||||||
|
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to create a test user for config tests
|
||||||
|
func CreateUser(app core.App, email string, password string) (*core.Record, error) {
|
||||||
|
userCollection, err := app.FindCachedCollectionByNameOrId("users")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := core.NewRecord(userCollection)
|
||||||
|
user.Set("email", email)
|
||||||
|
user.Set("password", password)
|
||||||
|
|
||||||
|
return user, app.Save(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create a test record
|
||||||
|
func CreateRecord(app core.App, collectionName string, fields map[string]any) (*core.Record, error) {
|
||||||
|
collection, err := app.FindCachedCollectionByNameOrId(collectionName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
record := core.NewRecord(collection)
|
||||||
|
record.Load(fields)
|
||||||
|
|
||||||
|
return record, app.Save(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearCollection(t testing.TB, app core.App, collectionName string) error {
|
||||||
|
_, err := app.DB().NewQuery(fmt.Sprintf("DELETE from %s", collectionName)).Execute()
|
||||||
|
recordCount, err := app.CountRecords(collectionName)
|
||||||
|
assert.EqualValues(t, recordCount, 0, "should have 0 records after clearing")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,13 +14,6 @@ type UserManager struct {
|
|||||||
app core.App
|
app core.App
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSettings struct {
|
|
||||||
ChartTime string `json:"chartTime"`
|
|
||||||
NotificationEmails []string `json:"emails"`
|
|
||||||
NotificationWebhooks []string `json:"webhooks"`
|
|
||||||
// Language string `json:"lang"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserManager(app core.App) *UserManager {
|
func NewUserManager(app core.App) *UserManager {
|
||||||
return &UserManager{
|
return &UserManager{
|
||||||
app: app,
|
app: app,
|
||||||
@@ -37,30 +31,26 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
|
|||||||
// Initialize user settings with defaults if not set
|
// Initialize user settings with defaults if not set
|
||||||
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
||||||
record := e.Record
|
record := e.Record
|
||||||
// intialize settings with defaults
|
// intialize settings with defaults (zero values can be ignored)
|
||||||
settings := UserSettings{
|
settings := struct {
|
||||||
// Language: "en",
|
ChartTime string `json:"chartTime"`
|
||||||
ChartTime: "1h",
|
Emails []string `json:"emails"`
|
||||||
NotificationEmails: []string{},
|
}{
|
||||||
NotificationWebhooks: []string{},
|
ChartTime: "1h",
|
||||||
}
|
}
|
||||||
record.UnmarshalJSONField("settings", &settings)
|
record.UnmarshalJSONField("settings", &settings)
|
||||||
if len(settings.NotificationEmails) == 0 {
|
// get user email from auth record
|
||||||
// get user email from auth record
|
var user struct {
|
||||||
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
|
Email string `db:"email"`
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
|
||||||
if user := record.ExpandedOne("user"); user != nil {
|
|
||||||
settings.NotificationEmails = []string{user.GetString("email")}
|
|
||||||
} else {
|
|
||||||
log.Println("Failed to get user email from auth record")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Println("failed to expand user relation", "errs", errs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// if len(settings.NotificationWebhooks) == 0 {
|
err := e.App.DB().NewQuery("SELECT email FROM users WHERE id = {:id}").Bind(dbx.Params{
|
||||||
// settings.NotificationWebhooks = []string{""}
|
"id": record.GetString("user"),
|
||||||
// }
|
}).One(&user)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("failed to get user email", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings.Emails = []string{user.Email}
|
||||||
record.Set("settings", settings)
|
record.Set("settings", settings)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
m.Register(func(app core.App) error {
|
m.Register(func(app core.App) error {
|
||||||
// delete duplicate alerts
|
// update collections
|
||||||
app.DB().NewQuery(`
|
|
||||||
DELETE FROM alerts
|
|
||||||
WHERE rowid NOT IN (
|
|
||||||
SELECT MAX(rowid)
|
|
||||||
FROM alerts
|
|
||||||
GROUP BY user, system, name
|
|
||||||
);
|
|
||||||
`).Execute()
|
|
||||||
|
|
||||||
// import collections
|
|
||||||
jsonData := `[
|
jsonData := `[
|
||||||
{
|
{
|
||||||
"id": "elngm8x1l60zi2v",
|
"id": "elngm8x1l60zi2v",
|
||||||
@@ -84,7 +75,10 @@ func init() {
|
|||||||
"Memory",
|
"Memory",
|
||||||
"Disk",
|
"Disk",
|
||||||
"Temperature",
|
"Temperature",
|
||||||
"Bandwidth"
|
"Bandwidth",
|
||||||
|
"LoadAvg1",
|
||||||
|
"LoadAvg5",
|
||||||
|
"LoadAvg15"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,6 +140,124 @@ func init() {
|
|||||||
],
|
],
|
||||||
"system": false
|
"system": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_1697146157",
|
||||||
|
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"name": "alerts_history",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2375276105",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "user",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "2hz5ncl8tizk5nx",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3377271179",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "system",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2466471794",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "alert_id",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number494360628",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "value",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date2276568630",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "resolved",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX ` + "`" + `idx_YdGnup5aqB` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `user` + "`" + `)",
|
||||||
|
"CREATE INDEX ` + "`" + `idx_taLet9VdME` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `created` + "`" + `)"
|
||||||
|
],
|
||||||
|
"system": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "juohu4jipgc13v7",
|
"id": "juohu4jipgc13v7",
|
||||||
"listRule": "@request.auth.id != \"\"",
|
"listRule": "@request.auth.id != \"\"",
|
||||||
@@ -236,6 +348,88 @@ func init() {
|
|||||||
],
|
],
|
||||||
"system": false
|
"system": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_3663931638",
|
||||||
|
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
||||||
|
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
||||||
|
"createRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
|
||||||
|
"updateRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
|
||||||
|
"deleteRule": null,
|
||||||
|
"name": "fingerprints",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{9}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 9,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "2hz5ncl8tizk5nx",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3377271179",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "system",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-zA-Z9-9]{20}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1597481275",
|
||||||
|
"max": 255,
|
||||||
|
"min": 9,
|
||||||
|
"name": "token",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text4228609354",
|
||||||
|
"max": 255,
|
||||||
|
"min": 9,
|
||||||
|
"name": "fingerprint",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX ` + "`" + `idx_p9qZlu26po` + "`" + ` ON ` + "`" + `fingerprints` + "`" + ` (` + "`" + `token` + "`" + `)",
|
||||||
|
"CREATE UNIQUE INDEX ` + "`" + `idx_ngboulGMYw` + "`" + ` ON ` + "`" + `fingerprints` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||||
|
],
|
||||||
|
"system": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "ej9oowivz8b2mht",
|
"id": "ej9oowivz8b2mht",
|
||||||
"listRule": "@request.auth.id != \"\"",
|
"listRule": "@request.auth.id != \"\"",
|
||||||
@@ -669,7 +863,37 @@ func init() {
|
|||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
|
err := app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all systems that don't have fingerprint records
|
||||||
|
var systemIds []string
|
||||||
|
err = app.DB().NewQuery(`
|
||||||
|
SELECT s.id FROM systems s
|
||||||
|
LEFT JOIN fingerprints f ON s.id = f.system
|
||||||
|
WHERE f.system IS NULL
|
||||||
|
`).Column(&systemIds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Create fingerprint records with unique UUID tokens for each system
|
||||||
|
for _, systemId := range systemIds {
|
||||||
|
token := uuid.New().String()
|
||||||
|
_, err = app.DB().NewQuery(`
|
||||||
|
INSERT INTO fingerprints (system, token)
|
||||||
|
VALUES ({:system}, {:token})
|
||||||
|
`).Bind(map[string]any{
|
||||||
|
"system": systemId,
|
||||||
|
"token": token,
|
||||||
|
}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}, func(app core.App) error {
|
}, func(app core.App) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
m "github.com/pocketbase/pocketbase/migrations"
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
const (
|
||||||
TempAdminEmail = "_@b.b"
|
TempAdminEmail = "_@b.b"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -4,12 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" />
|
<link rel="manifest" href="./static/manifest.json" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
<script>
|
<script>
|
||||||
globalThis.BESZEL = {
|
globalThis.BESZEL = {
|
||||||
BASE_PATH: "%BASE_URL%",
|
BASE_PATH: "%BASE_URL%",
|
||||||
HUB_VERSION: "{{V}}"
|
HUB_VERSION: "{{V}}",
|
||||||
|
HUB_URL: "{{HUB_URL}}"
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
5322
beszel/site/package-lock.json
generated
5322
beszel/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.11.1",
|
"version": "0.12.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "lingui extract --overwrite && lingui compile && vite build",
|
"build": "lingui extract --overwrite && lingui compile && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"sync": "lingui extract --overwrite && lingui compile",
|
"sync": "lingui extract --overwrite && lingui compile",
|
||||||
@@ -13,54 +13,54 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
"@lingui/detect-locale": "^5.2.0",
|
"@lingui/detect-locale": "^5.4.1",
|
||||||
"@lingui/macro": "^5.2.0",
|
"@lingui/macro": "^5.4.1",
|
||||||
"@lingui/react": "^5.2.0",
|
"@lingui/react": "^5.4.1",
|
||||||
"@nanostores/react": "^0.7.3",
|
"@nanostores/react": "^0.7.3",
|
||||||
"@nanostores/router": "^0.11.0",
|
"@nanostores/router": "^0.11.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-direction": "^1.1.0",
|
"@radix-ui/react-direction": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slider": "^1.2.3",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.2",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.1.1",
|
||||||
"d3-time": "^3.1.0",
|
"d3-time": "^3.1.0",
|
||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.4",
|
"nanostores": "^0.11.4",
|
||||||
"pocketbase": "^0.25.2",
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^18.3.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.4",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"valibot": "^0.42.1"
|
||||||
"valibot": "^0.42.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lingui/cli": "^5.2.0",
|
"@lingui/cli": "^5.4.1",
|
||||||
"@lingui/swc-plugin": "^5.5.0",
|
"@lingui/swc-plugin": "^5.6.1",
|
||||||
"@lingui/vite-plugin": "^5.2.0",
|
"@lingui/vite-plugin": "^5.4.1",
|
||||||
"@types/bun": "^1.2.4",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@types/react": "^18.3.1",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/bun": "^1.2.20",
|
||||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
"@types/react": "^19.1.11",
|
||||||
"autoprefixer": "^10.4.20",
|
"@types/react-dom": "^19.1.7",
|
||||||
"postcss": "^8.5.3",
|
"@vitejs/plugin-react-swc": "^4.0.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^4.1.12",
|
||||||
"tailwindcss-rtl": "^0.9.0",
|
"tw-animate-css": "^1.3.7",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^7.1.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@nanostores/router": {
|
"@nanostores/router": {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -11,20 +11,29 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { $publicKey, pb } from "@/lib/stores"
|
import { $publicKey } from "@/lib/stores"
|
||||||
import { cn, copyToClipboard, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
import { cn, generateToken, tokenMap, useBrowserStorage } from "@/lib/utils"
|
||||||
import { i18n } from "@lingui/core"
|
import { pb, isReadOnlyUser } from "@/lib/api"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { ChevronDownIcon, Copy, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
||||||
import { memo, useRef, useState } from "react"
|
import { memo, useEffect, useRef, useState } from "react"
|
||||||
import { basePath, navigate } from "./router"
|
import { $router, basePath, Link, navigate } from "./router"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
|
import { SystemStatus } from "@/lib/enums"
|
||||||
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
||||||
|
import { InputCopy } from "./ui/input-copy"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import {
|
||||||
|
copyDockerCompose,
|
||||||
|
copyDockerRun,
|
||||||
|
copyLinuxCommand,
|
||||||
|
copyWindowsCommand,
|
||||||
|
DropdownItem,
|
||||||
|
InstallDropdown,
|
||||||
|
} from "./install-dropdowns"
|
||||||
|
import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
||||||
|
|
||||||
export function AddSystemButton({ className }: { className?: string }) {
|
export function AddSystemButton({ className }: { className?: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -51,44 +60,11 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyDockerCompose(port = "45876", publicKey: string) {
|
/**
|
||||||
copyToClipboard(`services:
|
* Token to be used for the next system.
|
||||||
beszel-agent:
|
* Prevents token changing if user copies config, then closes dialog and opens again.
|
||||||
image: "henrygd/beszel-agent"
|
*/
|
||||||
container_name: "beszel-agent"
|
let nextSystemToken: string | null = null
|
||||||
restart: unless-stopped
|
|
||||||
network_mode: host
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
# monitor other disks / partitions by mounting a folder in /extra-filesystems
|
|
||||||
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
|
|
||||||
environment:
|
|
||||||
LISTEN: ${port}
|
|
||||||
KEY: "${publicKey}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyDockerRun(port = "45876", publicKey: string) {
|
|
||||||
copyToClipboard(
|
|
||||||
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -e KEY="${publicKey}" -e LISTEN=${port} henrygd/beszel-agent:latest`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyLinuxCommand(port = "45876", publicKey: string, brew = false) {
|
|
||||||
let cmd = `curl -sL https://get.beszel.dev${
|
|
||||||
brew ? "/brew" : ""
|
|
||||||
} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}"`
|
|
||||||
// brew script does not support --china-mirrors
|
|
||||||
if (!brew && (i18n.locale + navigator.language).includes("zh-CN")) {
|
|
||||||
cmd += ` --china-mirrors`
|
|
||||||
}
|
|
||||||
copyToClipboard(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyWindowsCommand(port = "45876", publicKey: string) {
|
|
||||||
copyToClipboard(
|
|
||||||
`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser; & iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\\install-agent.ps1"; & "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SystemDialog component for adding or editing a system.
|
* SystemDialog component for adding or editing a system.
|
||||||
@@ -96,12 +72,32 @@ function copyWindowsCommand(port = "45876", publicKey: string) {
|
|||||||
* @param {function} props.setOpen - Function to set the open state of the dialog.
|
* @param {function} props.setOpen - Function to set the open state of the dialog.
|
||||||
* @param {SystemRecord} [props.system] - Optional system record for editing an existing system.
|
* @param {SystemRecord} [props.system] - Optional system record for editing an existing system.
|
||||||
*/
|
*/
|
||||||
export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
|
export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
|
||||||
const publicKey = useStore($publicKey)
|
const publicKey = useStore($publicKey)
|
||||||
const port = useRef<HTMLInputElement>(null)
|
const port = useRef<HTMLInputElement>(null)
|
||||||
const [hostValue, setHostValue] = useState(system?.host ?? "")
|
const [hostValue, setHostValue] = useState(system?.host ?? "")
|
||||||
const isUnixSocket = hostValue.startsWith("/")
|
const isUnixSocket = hostValue.startsWith("/")
|
||||||
const [tab, setTab] = useLocalStorage("as-tab", "docker")
|
const [tab, setTab] = useBrowserStorage("as-tab", "docker")
|
||||||
|
const [token, setToken] = useState(system?.token ?? "")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
// if no system, generate a new token
|
||||||
|
if (!system) {
|
||||||
|
nextSystemToken ||= generateToken()
|
||||||
|
return setToken(nextSystemToken)
|
||||||
|
}
|
||||||
|
// if system exists,get the token from the fingerprint record
|
||||||
|
if (tokenMap.has(system.id)) {
|
||||||
|
return setToken(tokenMap.get(system.id)!)
|
||||||
|
}
|
||||||
|
const { token } = await pb.collection("fingerprints").getFirstListItem(`system = "${system.id}"`, {
|
||||||
|
fields: "token",
|
||||||
|
})
|
||||||
|
tokenMap.set(system.id, token)
|
||||||
|
setToken(token)
|
||||||
|
})()
|
||||||
|
}, [system?.id])
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -111,14 +107,20 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
|||||||
try {
|
try {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
if (system) {
|
if (system) {
|
||||||
await pb.collection("systems").update(system.id, { ...data, status: "pending" })
|
await pb.collection("systems").update(system.id, { ...data, status: SystemStatus.Pending })
|
||||||
} else {
|
} else {
|
||||||
await pb.collection("systems").create(data)
|
const createdSystem = await pb.collection("systems").create(data)
|
||||||
|
await pb.collection("fingerprints").create({
|
||||||
|
system: createdSystem.id,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
// Reset the current token after successful system
|
||||||
|
// creation so next system gets a new token
|
||||||
|
nextSystemToken = null
|
||||||
}
|
}
|
||||||
navigate(basePath)
|
navigate(basePath)
|
||||||
// console.log(record)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +133,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
|||||||
>
|
>
|
||||||
<Tabs defaultValue={tab} onValueChange={setTab}>
|
<Tabs defaultValue={tab} onValueChange={setTab}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="mb-2">
|
<DialogTitle className="mb-2 max-w-100 truncate pr-8">
|
||||||
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
|
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
@@ -143,18 +145,35 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
|
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
|
||||||
<TabsContent value="docker" tabIndex={-1}>
|
<TabsContent value="docker" tabIndex={-1}>
|
||||||
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
|
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
|
||||||
<Trans>
|
<Trans>
|
||||||
The agent must be running on the system to connect. Copy the
|
Copy the
|
||||||
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
|
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> content for the agent
|
||||||
|
below, or register agents automatically with a{" "}
|
||||||
|
<Link
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
href={getPagePath($router, "settings", { name: "tokens" })}
|
||||||
|
className="link"
|
||||||
|
>
|
||||||
|
universal token
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{/* Binary */}
|
{/* Binary */}
|
||||||
<TabsContent value="binary" tabIndex={-1}>
|
<TabsContent value="binary" tabIndex={-1}>
|
||||||
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
|
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
|
||||||
<Trans>
|
<Trans>
|
||||||
The agent must be running on the system to connect. Copy the installation command for the agent below.
|
Copy the installation command for the agent below, or register agents automatically with a{" "}
|
||||||
|
<Link
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
href={getPagePath($router, "settings", { name: "tokens" })}
|
||||||
|
className="link"
|
||||||
|
>
|
||||||
|
universal token
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -190,46 +209,27 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
|||||||
<Label htmlFor="pkey" className="xs:text-end whitespace-pre">
|
<Label htmlFor="pkey" className="xs:text-end whitespace-pre">
|
||||||
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
|
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<InputCopy value={publicKey} id="pkey" name="pkey" />
|
||||||
<Input readOnly id="pkey" value={publicKey} required></Input>
|
<Label htmlFor="tkn" className="xs:text-end whitespace-pre">
|
||||||
<div
|
<Trans>Token</Trans>
|
||||||
className={
|
</Label>
|
||||||
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
|
<InputCopy value={token} id="tkn" name="tkn" />
|
||||||
}
|
|
||||||
></div>
|
|
||||||
<TooltipProvider delayDuration={100}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={"link"}
|
|
||||||
className="absolute end-0 top-0"
|
|
||||||
onClick={() => copyToClipboard(publicKey)}
|
|
||||||
>
|
|
||||||
<Copy className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
<Trans>Click to copy</Trans>
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
|
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
|
||||||
{/* Docker */}
|
{/* Docker */}
|
||||||
<TabsContent value="docker" className="contents">
|
<TabsContent value="docker" className="contents">
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
|
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
|
||||||
onClick={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
onClick={async () =>
|
||||||
|
copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
|
||||||
|
}
|
||||||
icon={<DockerIcon className="size-4 -me-0.5" />}
|
icon={<DockerIcon className="size-4 -me-0.5" />}
|
||||||
dropdownItems={[
|
dropdownItems={[
|
||||||
{
|
{
|
||||||
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
|
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
|
||||||
onClick: () => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey),
|
onClick: async () =>
|
||||||
icons: [<DockerIcon className="size-4" />],
|
copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
|
||||||
|
icons: [DockerIcon],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -239,22 +239,24 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
|||||||
<CopyButton
|
<CopyButton
|
||||||
text={t`Copy Linux command`}
|
text={t`Copy Linux command`}
|
||||||
icon={<TuxIcon className="size-4" />}
|
icon={<TuxIcon className="size-4" />}
|
||||||
onClick={() => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
onClick={async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)}
|
||||||
dropdownItems={[
|
dropdownItems={[
|
||||||
{
|
{
|
||||||
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
|
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
|
||||||
onClick: () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, true),
|
onClick: async () =>
|
||||||
icons: [<AppleIcon className="size-4" />, <TuxIcon className="w-4 h-4" />],
|
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token, true),
|
||||||
|
icons: [AppleIcon, TuxIcon],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t({ message: "Windows command", context: "Button to copy install command" }),
|
text: t({ message: "Windows command", context: "Button to copy install command" }),
|
||||||
onClick: () => copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey),
|
onClick: async () =>
|
||||||
icons: [<WindowsIcon className="size-4" />],
|
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
|
||||||
|
icons: [WindowsIcon],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t`Manual setup instructions`,
|
text: t`Manual setup instructions`,
|
||||||
url: "https://beszel.dev/guide/agent-installation#binary",
|
url: "https://beszel.dev/guide/agent-installation#binary",
|
||||||
icons: [<ExternalLinkIcon className="size-4" />],
|
icons: [ExternalLinkIcon],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -266,20 +268,13 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
interface DropdownItem {
|
|
||||||
text: string
|
|
||||||
onClick?: () => void
|
|
||||||
url?: string
|
|
||||||
icons?: React.ReactNode[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CopyButtonProps {
|
interface CopyButtonProps {
|
||||||
text: string
|
text: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
dropdownItems: DropdownItem[]
|
dropdownItems: DropdownItem[]
|
||||||
icon?: React.ReactNode
|
icon?: React.ReactElement<any>
|
||||||
}
|
}
|
||||||
|
|
||||||
const CopyButton = memo((props: CopyButtonProps) => {
|
const CopyButton = memo((props: CopyButtonProps) => {
|
||||||
@@ -300,26 +295,7 @@ const CopyButton = memo((props: CopyButtonProps) => {
|
|||||||
<ChevronDownIcon />
|
<ChevronDownIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<InstallDropdown items={props.dropdownItems} />
|
||||||
{props.dropdownItems.map((item, index) => (
|
|
||||||
<DropdownMenuItem key={index} asChild={!!item.url}>
|
|
||||||
{item.url ? (
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
className="cursor-pointer flex items-center gap-1.5"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{item.text} {item.icons?.map((icon) => icon)}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div onClick={item.onClick} className="cursor-pointer flex items-center gap-1.5">
|
|
||||||
{item.text} {item.icons?.map((icon) => icon)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
167
beszel/site/src/components/alerts-history-columns.tsx
Normal file
167
beszel/site/src/components/alerts-history-columns.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { AlertsHistoryRecord } from "@/types"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
|
||||||
|
import { alertInfo } from "@/lib/alerts"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
|
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "system",
|
||||||
|
enableSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="ps-2 max-w-60 truncate">{row.original.expand?.system?.name || row.original.system}</div>
|
||||||
|
),
|
||||||
|
filterFn: (row, _, filterValue) => {
|
||||||
|
const display = row.original.expand?.system?.name || row.original.system || ""
|
||||||
|
return display.toLowerCase().includes(filterValue.toLowerCase())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// accessorKey: "name",
|
||||||
|
id: "name",
|
||||||
|
accessorFn: (record) => {
|
||||||
|
const name = record.name
|
||||||
|
const info = alertInfo[name]
|
||||||
|
return info?.name().replace("cpu", "CPU") || name
|
||||||
|
},
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ getValue, row }) => {
|
||||||
|
let name = getValue() as string
|
||||||
|
const info = alertInfo[row.original.name]
|
||||||
|
const Icon = info?.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2 ps-1 min-w-40">
|
||||||
|
{Icon && <Icon className="size-3.5" />}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "value",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => (
|
||||||
|
<Button variant="ghost">
|
||||||
|
<Trans>Value</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell({ row, getValue }) {
|
||||||
|
const name = row.original.name
|
||||||
|
if (name === "Status") {
|
||||||
|
return <span className="ps-2">{t`Down`}</span>
|
||||||
|
}
|
||||||
|
const value = getValue() as number
|
||||||
|
const unit = alertInfo[name]?.unit
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums ps-2.5">
|
||||||
|
{toFixedFloat(value, value < 10 ? 2 : 1)}
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "state",
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0),
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans comment="Context: alert state (active or resolved)">State</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resolved = row.original.resolved
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"capitalize pointer-events-none",
|
||||||
|
resolved
|
||||||
|
? "bg-green-100 text-green-800 border-green-200 dark:opacity-80"
|
||||||
|
: "bg-yellow-100 text-yellow-800 border-yellow-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* {resolved ? <CircleCheckIcon className="size-3 me-0.5" /> : <CircleAlertIcon className="size-3 me-0.5" />} */}
|
||||||
|
{resolved ? <Trans>Resolved</Trans> : <Trans>Active</Trans>}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created",
|
||||||
|
accessorFn: (record) => formatShortDate(record.created),
|
||||||
|
enableSorting: true,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans comment="Context: date created">Created</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.created} UTC`}>
|
||||||
|
{getValue() as string}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "resolved",
|
||||||
|
enableSorting: true,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>Resolved</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row, getValue }) => {
|
||||||
|
const resolved = getValue() as string | null
|
||||||
|
if (!resolved) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.resolved} UTC`}>
|
||||||
|
{formatShortDate(resolved)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "duration",
|
||||||
|
invertSorting: true,
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const aCreated = new Date(rowA.original.created)
|
||||||
|
const bCreated = new Date(rowB.original.created)
|
||||||
|
const aResolved = rowA.original.resolved ? new Date(rowA.original.resolved) : null
|
||||||
|
const bResolved = rowB.original.resolved ? new Date(rowB.original.resolved) : null
|
||||||
|
const aDuration = aResolved ? aResolved.getTime() - aCreated.getTime() : null
|
||||||
|
const bDuration = bResolved ? bResolved.getTime() - bCreated.getTime() : null
|
||||||
|
if (!aDuration && bDuration) return -1
|
||||||
|
if (aDuration && !bDuration) return 1
|
||||||
|
return (aDuration || 0) - (bDuration || 0)
|
||||||
|
},
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>Duration</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const duration = formatDuration(row.original.created, row.original.resolved)
|
||||||
|
if (!duration) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return <span className="ps-2">{duration}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,132 +1,36 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro";
|
|
||||||
import { memo, useMemo, useState } from "react"
|
import { memo, useMemo, useState } from "react"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $alerts } from "@/lib/stores"
|
import { $alerts } from "@/lib/stores"
|
||||||
import {
|
import { BellIcon } from "lucide-react"
|
||||||
Dialog,
|
import { cn } from "@/lib/utils"
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
|
|
||||||
import { alertInfo, cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
import { Link } from "../router"
|
import { AlertDialogContent } from "./alerts-sheet"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||||
import { Checkbox } from "../ui/checkbox"
|
|
||||||
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
|
|
||||||
|
|
||||||
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
|
||||||
const hasAlert = alerts.some((alert) => alert.system === system.id)
|
const hasSystemAlert = alerts[system.id]?.size > 0
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<Dialog>
|
<Sheet>
|
||||||
<DialogTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||||
<BellIcon
|
<BellIcon
|
||||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
||||||
"fill-primary": hasAlert,
|
"fill-primary": hasSystemAlert,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</SheetTrigger>
|
||||||
<DialogContent className="max-h-full overflow-auto max-w-[35rem]">
|
<SheetContent className="max-h-full overflow-auto w-145 !max-w-full p-4 sm:p-6">
|
||||||
{opened && <AlertDialogContent system={system} />}
|
{opened && <AlertDialogContent system={system} />}
|
||||||
</DialogContent>
|
</SheetContent>
|
||||||
</Dialog>
|
</Sheet>
|
||||||
),
|
),
|
||||||
[opened, hasAlert]
|
[opened, hasSystemAlert]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function AlertDialogContent({ system }: { system: SystemRecord }) {
|
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
|
||||||
|
|
||||||
// alertsSignature changes only when alerts for this system change
|
|
||||||
let alertsSignature = ""
|
|
||||||
const systemAlerts = alerts.filter((alert) => {
|
|
||||||
if (alert.system === system.id) {
|
|
||||||
alertsSignature += alert.name + alert.min + alert.value
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}) as AlertRecord[]
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
// console.log("render modal", system.name, alertsSignature)
|
|
||||||
const data = Object.keys(alertInfo).map((name) => {
|
|
||||||
const alert = alertInfo[name as keyof typeof alertInfo]
|
|
||||||
return {
|
|
||||||
name: name as keyof typeof alertInfo,
|
|
||||||
alert,
|
|
||||||
system,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl">
|
|
||||||
<Trans>Alerts</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
See{" "}
|
|
||||||
<Link href="/settings/notifications" className="link">
|
|
||||||
notification settings
|
|
||||||
</Link>{" "}
|
|
||||||
to configure how you receive alerts.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Tabs defaultValue="system">
|
|
||||||
<TabsList className="mb-1 -mt-0.5">
|
|
||||||
<TabsTrigger value="system">
|
|
||||||
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
|
||||||
{system.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="global">
|
|
||||||
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
|
||||||
<Trans>All Systems</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="system">
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{data.map((d) => (
|
|
||||||
<SystemAlert key={d.name} system={system} data={d} systemAlerts={systemAlerts} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="global">
|
|
||||||
<label
|
|
||||||
htmlFor="ovw"
|
|
||||||
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id="ovw"
|
|
||||||
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
|
|
||||||
checked={overwriteExisting}
|
|
||||||
onCheckedChange={setOverwriteExisting}
|
|
||||||
/>
|
|
||||||
<Trans>Overwrite existing alerts</Trans>
|
|
||||||
</label>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{data.map((d) => (
|
|
||||||
<SystemAlertGlobal key={d.name} data={d} overwrite={overwriteExisting} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}, [alertsSignature, overwriteExisting])
|
|
||||||
}
|
|
||||||
|
|||||||
299
beszel/site/src/components/alerts/alerts-sheet.tsx
Normal file
299
beszel/site/src/components/alerts/alerts-sheet.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans, Plural } from "@lingui/react/macro"
|
||||||
|
import { $alerts, $systems } from "@/lib/stores"
|
||||||
|
import { cn, debounce } from "@/lib/utils"
|
||||||
|
import { alertInfo } from "@/lib/alerts"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
||||||
|
import { lazy, memo, Suspense, useMemo, useState } from "react"
|
||||||
|
import { toast } from "@/components/ui/use-toast"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
|
import { ServerIcon, GlobeIcon } from "lucide-react"
|
||||||
|
import { $router, Link } from "@/components/router"
|
||||||
|
import { DialogHeader } from "@/components/ui/dialog"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
|
||||||
|
const Slider = lazy(() => import("@/components/ui/slider"))
|
||||||
|
|
||||||
|
const endpoint = "/api/beszel/user-alerts"
|
||||||
|
|
||||||
|
const alertDebounce = 100
|
||||||
|
|
||||||
|
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
|
||||||
|
|
||||||
|
const failedUpdateToast = (error: unknown) => {
|
||||||
|
console.error(error)
|
||||||
|
toast({
|
||||||
|
title: t`Failed to update alert`,
|
||||||
|
description: t`Please check logs for more details.`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create or update alerts for a given name and systems */
|
||||||
|
const upsertAlerts = debounce(
|
||||||
|
async ({ name, value, min, systems }: { name: string; value: number; min: number; systems: string[] }) => {
|
||||||
|
try {
|
||||||
|
await pb.send<{ success: boolean }>(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
// overwrite is always true because we've done filtering client side
|
||||||
|
body: { name, value, min, systems, overwrite: true },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
failedUpdateToast(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
alertDebounce
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Delete alerts for a given name and systems */
|
||||||
|
const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems: string[] }) => {
|
||||||
|
try {
|
||||||
|
await pb.send<{ success: boolean }>(endpoint, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: { name, systems },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
failedUpdateToast(error)
|
||||||
|
}
|
||||||
|
}, alertDebounce)
|
||||||
|
|
||||||
|
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||||
|
const [currentTab, setCurrentTab] = useState("system")
|
||||||
|
|
||||||
|
const systemAlerts = alerts[system.id] ?? new Map()
|
||||||
|
|
||||||
|
// We need to keep a copy of alerts when we switch to global tab. If we always compare to
|
||||||
|
// current alerts, it will only be updated when first checked, then won't be updated because
|
||||||
|
// after that it exists.
|
||||||
|
const alertsWhenGlobalSelected = useMemo(() => {
|
||||||
|
return currentTab === "global" ? structuredClone(alerts) : alerts
|
||||||
|
}, [currentTab])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl">
|
||||||
|
<Trans>Alerts</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
See{" "}
|
||||||
|
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
|
||||||
|
notification settings
|
||||||
|
</Link>{" "}
|
||||||
|
to configure how you receive alerts.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
|
||||||
|
<TabsList className="mb-1 -mt-0.5">
|
||||||
|
<TabsTrigger value="system">
|
||||||
|
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
||||||
|
<span className="truncate max-w-60">{system.name}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="global">
|
||||||
|
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
||||||
|
<Trans>All Systems</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="system">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{alertKeys.map((name) => (
|
||||||
|
<AlertContent
|
||||||
|
key={name}
|
||||||
|
alertKey={name}
|
||||||
|
data={alertInfo[name as keyof typeof alertInfo]}
|
||||||
|
alert={systemAlerts.get(name)}
|
||||||
|
system={system}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="global">
|
||||||
|
<label
|
||||||
|
htmlFor="ovw"
|
||||||
|
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="ovw"
|
||||||
|
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
|
||||||
|
checked={overwriteExisting}
|
||||||
|
onCheckedChange={setOverwriteExisting}
|
||||||
|
/>
|
||||||
|
<Trans>Overwrite existing alerts</Trans>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{alertKeys.map((name) => (
|
||||||
|
<AlertContent
|
||||||
|
key={name}
|
||||||
|
alertKey={name}
|
||||||
|
system={system}
|
||||||
|
alert={systemAlerts.get(name)}
|
||||||
|
data={alertInfo[name as keyof typeof alertInfo]}
|
||||||
|
global={true}
|
||||||
|
overwriteExisting={!!overwriteExisting}
|
||||||
|
initialAlertsState={alertsWhenGlobalSelected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function AlertContent({
|
||||||
|
alertKey,
|
||||||
|
data: alertData,
|
||||||
|
system,
|
||||||
|
alert,
|
||||||
|
global = false,
|
||||||
|
overwriteExisting = false,
|
||||||
|
initialAlertsState = {},
|
||||||
|
}: {
|
||||||
|
alertKey: string
|
||||||
|
data: AlertInfo
|
||||||
|
system: SystemRecord
|
||||||
|
alert?: AlertRecord
|
||||||
|
global?: boolean
|
||||||
|
overwriteExisting?: boolean
|
||||||
|
initialAlertsState?: Record<string, Map<string, AlertRecord>>
|
||||||
|
}) {
|
||||||
|
const { name } = alertData
|
||||||
|
|
||||||
|
const singleDescription = alertData.singleDesc?.()
|
||||||
|
|
||||||
|
const [checked, setChecked] = useState(global ? false : !!alert)
|
||||||
|
const [min, setMin] = useState(alert?.min || 10)
|
||||||
|
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : alertData.start ?? 80))
|
||||||
|
|
||||||
|
const Icon = alertData.icon
|
||||||
|
|
||||||
|
/** Get system ids to update */
|
||||||
|
function getSystemIds(): string[] {
|
||||||
|
// if not global, update only the current system
|
||||||
|
if (!global) {
|
||||||
|
return [system.id]
|
||||||
|
}
|
||||||
|
// if global, update all systems when overwriteExisting is true
|
||||||
|
// update only systems without an existing alert when overwriteExisting is false
|
||||||
|
const allSystems = $systems.get()
|
||||||
|
const systemIds: string[] = []
|
||||||
|
for (const system of allSystems) {
|
||||||
|
if (overwriteExisting || !initialAlertsState[system.id]?.has(alertKey)) {
|
||||||
|
systemIds.push(system.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemIds
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendUpsert(min: number, value: number) {
|
||||||
|
const systems = getSystemIds()
|
||||||
|
systems.length &&
|
||||||
|
upsertAlerts({
|
||||||
|
name: alertKey,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
systems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
||||||
|
<label
|
||||||
|
htmlFor={`s${name}`}
|
||||||
|
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
||||||
|
"pb-0": checked,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1 select-none">
|
||||||
|
<p className="font-semibold flex gap-3 items-center">
|
||||||
|
<Icon className="h-4 w-4 opacity-85" /> {alertData.name()}
|
||||||
|
</p>
|
||||||
|
{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`s${name}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(newChecked) => {
|
||||||
|
setChecked(newChecked)
|
||||||
|
if (newChecked) {
|
||||||
|
// if alert checked, create or update alert
|
||||||
|
sendUpsert(min, value)
|
||||||
|
} else {
|
||||||
|
// if unchecked, delete alert (unless global and overwriteExisting is false)
|
||||||
|
deleteAlerts({ name: alertKey, systems: getSystemIds() })
|
||||||
|
// when force deleting all alerts of a type, also remove them from initialAlertsState
|
||||||
|
if (overwriteExisting) {
|
||||||
|
for (const curAlerts of Object.values(initialAlertsState)) {
|
||||||
|
curAlerts.delete(alertKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{checked && (
|
||||||
|
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
|
||||||
|
<Suspense fallback={<div className="h-10" />}>
|
||||||
|
{!singleDescription && (
|
||||||
|
<div>
|
||||||
|
<p id={`v${name}`} className="text-sm block h-8">
|
||||||
|
<Trans>
|
||||||
|
Average exceeds{" "}
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{value}
|
||||||
|
{alertData.unit}
|
||||||
|
</strong>
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Slider
|
||||||
|
aria-labelledby={`v${name}`}
|
||||||
|
defaultValue={[value]}
|
||||||
|
onValueCommit={(val) => sendUpsert(min, val[0])}
|
||||||
|
onValueChange={(val) => setValue(val[0])}
|
||||||
|
step={alertData.step ?? 1}
|
||||||
|
min={alertData.min ?? 1}
|
||||||
|
max={alertData.max ?? 99}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
||||||
|
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
|
||||||
|
{singleDescription && (
|
||||||
|
<>
|
||||||
|
{singleDescription}
|
||||||
|
{` `}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Trans>
|
||||||
|
For <strong className="text-foreground">{min}</strong>{" "}
|
||||||
|
<Plural value={min} one="minute" other="minutes" />
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Slider
|
||||||
|
aria-labelledby={`v${name}`}
|
||||||
|
defaultValue={[min]}
|
||||||
|
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
|
||||||
|
onValueChange={(val) => setMin(val[0])}
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { Trans, Plural } from "@lingui/react/macro"
|
|
||||||
import { $alerts, $systems, pb } from "@/lib/stores"
|
|
||||||
import { alertInfo, cn } from "@/lib/utils"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
|
||||||
import { lazy, Suspense, useMemo, useState } from "react"
|
|
||||||
import { toast } from "../ui/use-toast"
|
|
||||||
import { BatchService } from "pocketbase"
|
|
||||||
import { getSemaphore } from "@henrygd/semaphore"
|
|
||||||
|
|
||||||
interface AlertData {
|
|
||||||
checked?: boolean
|
|
||||||
val?: number
|
|
||||||
min?: number
|
|
||||||
updateAlert?: (checked: boolean, value: number, min: number) => void
|
|
||||||
name: keyof typeof alertInfo
|
|
||||||
alert: AlertInfo
|
|
||||||
system: SystemRecord
|
|
||||||
}
|
|
||||||
|
|
||||||
const Slider = lazy(() => import("@/components/ui/slider"))
|
|
||||||
|
|
||||||
const failedUpdateToast = () =>
|
|
||||||
toast({
|
|
||||||
title: t`Failed to update alert`,
|
|
||||||
description: t`Please check logs for more details.`,
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
|
|
||||||
export function SystemAlert({
|
|
||||||
system,
|
|
||||||
systemAlerts,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
system: SystemRecord
|
|
||||||
systemAlerts: AlertRecord[]
|
|
||||||
data: AlertData
|
|
||||||
}) {
|
|
||||||
const alert = systemAlerts.find((alert) => alert.name === data.name)
|
|
||||||
|
|
||||||
data.updateAlert = async (checked: boolean, value: number, min: number) => {
|
|
||||||
try {
|
|
||||||
if (alert && !checked) {
|
|
||||||
await pb.collection("alerts").delete(alert.id)
|
|
||||||
} else if (alert && checked) {
|
|
||||||
await pb.collection("alerts").update(alert.id, { value, min, triggered: false })
|
|
||||||
} else if (checked) {
|
|
||||||
pb.collection("alerts").create({
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.record!.id,
|
|
||||||
name: data.name,
|
|
||||||
value: value,
|
|
||||||
min: min,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
failedUpdateToast()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alert) {
|
|
||||||
data.checked = true
|
|
||||||
data.val = alert.value
|
|
||||||
data.min = alert.min || 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AlertContent data={data} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SystemAlertGlobal = ({ data, overwrite }: { data: AlertData; overwrite: boolean | "indeterminate" }) => {
|
|
||||||
data.checked = false
|
|
||||||
data.val = data.min = 0
|
|
||||||
|
|
||||||
// set of system ids that have an alert for this name when the component is mounted
|
|
||||||
const existingAlertsSystems = useMemo(() => {
|
|
||||||
const map = new Set<string>()
|
|
||||||
const alerts = $alerts.get()
|
|
||||||
for (const alert of alerts) {
|
|
||||||
if (alert.name === data.name) {
|
|
||||||
map.add(alert.system)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
data.updateAlert = async (checked: boolean, value: number, min: number) => {
|
|
||||||
const sem = getSemaphore("alerts")
|
|
||||||
await sem.acquire()
|
|
||||||
try {
|
|
||||||
// if another update is waiting behind, don't start this one
|
|
||||||
if (sem.size() > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordData: Partial<AlertRecord> = {
|
|
||||||
value,
|
|
||||||
min,
|
|
||||||
triggered: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const batch = batchWrapper("alerts", 25)
|
|
||||||
const systems = $systems.get()
|
|
||||||
const currentAlerts = $alerts.get()
|
|
||||||
|
|
||||||
// map of current alerts with this name right now by system id
|
|
||||||
const currentAlertsSystems = new Map<string, AlertRecord>()
|
|
||||||
for (const alert of currentAlerts) {
|
|
||||||
if (alert.name === data.name) {
|
|
||||||
currentAlertsSystems.set(alert.system, alert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overwrite) {
|
|
||||||
existingAlertsSystems.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
const processSystem = async (system: SystemRecord): Promise<void> => {
|
|
||||||
const existingAlert = existingAlertsSystems.has(system.id)
|
|
||||||
|
|
||||||
if (!overwrite && existingAlert) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentAlert = currentAlertsSystems.get(system.id)
|
|
||||||
|
|
||||||
// delete existing alert if unchecked
|
|
||||||
if (!checked && currentAlert) {
|
|
||||||
return batch.remove(currentAlert.id)
|
|
||||||
}
|
|
||||||
if (checked && currentAlert) {
|
|
||||||
// update existing alert if checked
|
|
||||||
return batch.update(currentAlert.id, recordData)
|
|
||||||
}
|
|
||||||
if (checked) {
|
|
||||||
// create new alert if checked and not existing
|
|
||||||
return batch.create({
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.record!.id,
|
|
||||||
name: data.name,
|
|
||||||
...recordData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure current system is updated in the first batch
|
|
||||||
await processSystem(data.system)
|
|
||||||
for (const system of systems) {
|
|
||||||
if (system.id === data.system.id) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (sem.size() > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await processSystem(system)
|
|
||||||
}
|
|
||||||
await batch.send()
|
|
||||||
} finally {
|
|
||||||
sem.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AlertContent data={data} />
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a wrapper for performing batch operations on a specified collection.
|
|
||||||
*/
|
|
||||||
function batchWrapper(collection: string, batchSize: number) {
|
|
||||||
let batch: BatchService | undefined
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
const create = async <T extends Record<string, any>>(options: T) => {
|
|
||||||
batch ||= pb.createBatch()
|
|
||||||
batch.collection(collection).create(options)
|
|
||||||
if (++count >= batchSize) {
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = async <T extends Record<string, any>>(id: string, data: T) => {
|
|
||||||
batch ||= pb.createBatch()
|
|
||||||
batch.collection(collection).update(id, data)
|
|
||||||
if (++count >= batchSize) {
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
|
||||||
batch ||= pb.createBatch()
|
|
||||||
batch.collection(collection).delete(id)
|
|
||||||
if (++count >= batchSize) {
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const send = async () => {
|
|
||||||
if (count) {
|
|
||||||
await batch?.send({ requestKey: null })
|
|
||||||
batch = undefined
|
|
||||||
count = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
update,
|
|
||||||
remove,
|
|
||||||
send,
|
|
||||||
create,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertContent({ data }: { data: AlertData }) {
|
|
||||||
const { name } = data
|
|
||||||
|
|
||||||
const singleDescription = data.alert.singleDesc?.()
|
|
||||||
|
|
||||||
const [checked, setChecked] = useState(data.checked || false)
|
|
||||||
const [min, setMin] = useState(data.min || 10)
|
|
||||||
const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80))
|
|
||||||
|
|
||||||
const Icon = alertInfo[name].icon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
|
||||||
<label
|
|
||||||
htmlFor={`s${name}`}
|
|
||||||
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
|
||||||
"pb-0": checked,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="grid gap-1 select-none">
|
|
||||||
<p className="font-semibold flex gap-3 items-center">
|
|
||||||
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
|
||||||
</p>
|
|
||||||
{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id={`s${name}`}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={(newChecked) => {
|
|
||||||
setChecked(newChecked)
|
|
||||||
data.updateAlert?.(newChecked, value, min)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{checked && (
|
|
||||||
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
|
|
||||||
<Suspense fallback={<div className="h-10" />}>
|
|
||||||
{!singleDescription && (
|
|
||||||
<div>
|
|
||||||
<p id={`v${name}`} className="text-sm block h-8">
|
|
||||||
<Trans>
|
|
||||||
Average exceeds{" "}
|
|
||||||
<strong className="text-foreground">
|
|
||||||
{value}
|
|
||||||
{data.alert.unit}
|
|
||||||
</strong>
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Slider
|
|
||||||
aria-labelledby={`v${name}`}
|
|
||||||
defaultValue={[value]}
|
|
||||||
onValueCommit={(val) => {
|
|
||||||
data.updateAlert?.(true, val[0], min)
|
|
||||||
}}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setValue(val[0])
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
max={alertInfo[name].max ?? 99}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
|
||||||
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
|
|
||||||
{singleDescription && (
|
|
||||||
<>
|
|
||||||
{singleDescription}
|
|
||||||
{` `}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Trans>
|
|
||||||
For <strong className="text-foreground">{min}</strong>{" "}
|
|
||||||
<Plural value={min} one="minute" other="minutes" />
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Slider
|
|
||||||
aria-labelledby={`v${name}`}
|
|
||||||
defaultValue={[min]}
|
|
||||||
onValueCommit={(min) => {
|
|
||||||
data.updateAlert?.(true, value, min[0])
|
|
||||||
}}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setMin(val[0])
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
max={60}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,150 +1,94 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
|
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import {
|
import { cn, formatShortDate, chartMargin } from "@/lib/utils"
|
||||||
useYAxisWidth,
|
import { useYAxisWidth } from "./hooks"
|
||||||
cn,
|
import { ChartData, SystemStatsRecord } from "@/types"
|
||||||
formatShortDate,
|
import { useMemo } from "react"
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
decimalString,
|
|
||||||
chartMargin,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { ChartData } from "@/types"
|
|
||||||
import { memo, useMemo } from "react"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
|
|
||||||
/** [label, key, color, opacity] */
|
export type DataPoint = {
|
||||||
type DataKeys = [string, string, number, number]
|
label: string
|
||||||
|
dataKey: (data: SystemStatsRecord) => number | undefined
|
||||||
const getNestedValue = (path: string, max = false, data: any): number | null => {
|
color: number | string
|
||||||
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
|
opacity: number
|
||||||
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
|
|
||||||
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
|
|
||||||
// if not, return null - there is no max data so do not display anything.
|
|
||||||
return `stats.${path}${max ? "m" : ""}`
|
|
||||||
.split(".")
|
|
||||||
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function AreaChartDefault({
|
export default function AreaChartDefault({
|
||||||
maxToggled = false,
|
|
||||||
unit = " MB/s",
|
|
||||||
chartName,
|
|
||||||
chartData,
|
chartData,
|
||||||
max,
|
max,
|
||||||
|
maxToggled,
|
||||||
tickFormatter,
|
tickFormatter,
|
||||||
contentFormatter,
|
contentFormatter,
|
||||||
}: {
|
dataPoints,
|
||||||
maxToggled?: boolean
|
domain,
|
||||||
unit?: string
|
}: // logRender = false,
|
||||||
chartName: string
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
max?: number
|
max?: number
|
||||||
tickFormatter?: (value: number) => string
|
maxToggled?: boolean
|
||||||
contentFormatter?: (value: number) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
|
dataPoints?: DataPoint[]
|
||||||
|
domain?: [number, number]
|
||||||
|
// logRender?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { i18n } = useLingui()
|
|
||||||
|
|
||||||
const { chartTime } = chartData
|
return useMemo(() => {
|
||||||
|
if (chartData.systemStats.length === 0) {
|
||||||
const showMax = chartTime !== "1h" && maxToggled
|
return null
|
||||||
|
|
||||||
const dataKeys: DataKeys[] = useMemo(() => {
|
|
||||||
// [label, key, color, opacity]
|
|
||||||
if (chartName === "CPU Usage") {
|
|
||||||
return [[t`CPU Usage`, "cpu", 1, 0.4]]
|
|
||||||
} else if (chartName === "dio") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Write", comment: "Disk write" }), "dw", 3, 0.3],
|
|
||||||
[t({ message: "Read", comment: "Disk read" }), "dr", 1, 0.3],
|
|
||||||
]
|
|
||||||
} else if (chartName === "bw") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Sent", comment: "Network bytes sent (upload)" }), "ns", 5, 0.2],
|
|
||||||
[t({ message: "Received", comment: "Network bytes received (download)" }), "nr", 2, 0.2],
|
|
||||||
]
|
|
||||||
} else if (chartName.startsWith("efs")) {
|
|
||||||
return [
|
|
||||||
[t`Write`, `${chartName}.w`, 3, 0.3],
|
|
||||||
[t`Read`, `${chartName}.r`, 1, 0.3],
|
|
||||||
]
|
|
||||||
} else if (chartName.startsWith("g.")) {
|
|
||||||
return [chartName.includes("mu") ? [t`Used`, chartName, 2, 0.25] : [t`Usage`, chartName, 1, 0.4]]
|
|
||||||
}
|
}
|
||||||
return []
|
// if (logRender) {
|
||||||
}, [chartName, i18n.locale])
|
// console.log("Rendered at", new Date())
|
||||||
|
// }
|
||||||
// console.log('Rendered at', new Date())
|
return (
|
||||||
|
<div>
|
||||||
if (chartData.systemStats.length === 0) {
|
<ChartContainer
|
||||||
return null
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
}
|
"opacity-100": yAxisWidth,
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ChartContainer
|
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
|
||||||
"opacity-100": yAxisWidth,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
className="tracking-tighter"
|
|
||||||
width={yAxisWidth}
|
|
||||||
domain={[0, max ?? "auto"]}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
let val: string
|
|
||||||
if (tickFormatter) {
|
|
||||||
val = tickFormatter(value)
|
|
||||||
} else {
|
|
||||||
val = toFixedWithoutTrailingZeros(value, 2) + unit
|
|
||||||
}
|
|
||||||
return updateYAxisWidth(val)
|
|
||||||
}}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
{xAxis(chartData)}
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
|
||||||
contentFormatter={({ value }) => {
|
|
||||||
if (contentFormatter) {
|
|
||||||
return contentFormatter(value)
|
|
||||||
}
|
|
||||||
return decimalString(value) + unit
|
|
||||||
}}
|
|
||||||
// indicator="line"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{dataKeys.map((key, i) => {
|
|
||||||
const color = `hsl(var(--chart-${key[2]}))`
|
|
||||||
return (
|
|
||||||
<Area
|
|
||||||
key={i}
|
|
||||||
dataKey={getNestedValue.bind(null, key[1], showMax)}
|
|
||||||
name={key[0]}
|
|
||||||
type="monotoneX"
|
|
||||||
fill={color}
|
|
||||||
fillOpacity={key[3]}
|
|
||||||
stroke={color}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
{/* <ChartLegend content={<ChartLegendContent />} /> */}
|
>
|
||||||
</AreaChart>
|
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
</ChartContainer>
|
<CartesianGrid vertical={false} />
|
||||||
</div>
|
<YAxis
|
||||||
)
|
direction="ltr"
|
||||||
})
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
width={yAxisWidth}
|
||||||
|
domain={domain ?? [0, max ?? "auto"]}
|
||||||
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={contentFormatter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{dataPoints?.map((dataPoint, i) => {
|
||||||
|
const color = `var(--chart-${dataPoint.color})`
|
||||||
|
return (
|
||||||
|
<Area
|
||||||
|
key={i}
|
||||||
|
dataKey={dataPoint.dataKey}
|
||||||
|
name={dataPoint.label}
|
||||||
|
type="monotoneX"
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={dataPoint.opacity}
|
||||||
|
stroke={color}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* <ChartLegend content={<ChartLegendContent />} /> */}
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user