mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-29 06:48:14 +01:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
372132b1da | ||
|
|
06be1744ae | ||
|
|
6c6e13704e | ||
|
|
d34b62e2f5 | ||
|
|
e6bd7c2462 | ||
|
|
8b985654ef | ||
|
|
1543ffa19f | ||
|
|
730e3a2ab4 | ||
|
|
ba4af8fe77 | ||
|
|
b788e6e338 | ||
|
|
ef3aa146b5 | ||
|
|
e222e693d7 | ||
|
|
277a485afe | ||
|
|
211c466fc3 | ||
|
|
f96884c62b | ||
|
|
8b4f10f15a | ||
|
|
6c9b1fe45c | ||
|
|
73cba8b508 | ||
|
|
0633cacb2a | ||
|
|
bf5b231e52 | ||
|
|
9cda7febb4 | ||
|
|
b3d4255868 | ||
|
|
9c2051840f | ||
|
|
1a3810db3a | ||
|
|
2335ef0fb1 | ||
|
|
fc73803bc1 | ||
|
|
59953fed30 | ||
|
|
57a2ca26db | ||
|
|
09ddb925a3 | ||
|
|
55e09c02b1 | ||
|
|
9adeb3e3dd | ||
|
|
0f087edfd6 | ||
|
|
c29798a48b | ||
|
|
c202e26559 | ||
|
|
568d24d746 | ||
|
|
cdd1353102 | ||
|
|
b4646b665f | ||
|
|
c191676565 | ||
|
|
9a96f3cc53 | ||
|
|
95a72930b5 | ||
|
|
71e5a507ba | ||
|
|
8f7ef5a015 | ||
|
|
a824e4c8c2 | ||
|
|
62fb690417 | ||
|
|
9f036a61f8 | ||
|
|
cdd60d99cd | ||
|
|
e718cd4c4a | ||
|
|
8ce821adb9 | ||
|
|
92598e05a2 | ||
|
|
1c0cd1ff03 | ||
|
|
630629a3fd | ||
|
|
a1f7375e7b | ||
|
|
dba6a4fedf | ||
|
|
6b752059da | ||
|
|
262d386a97 | ||
|
|
8df7eb2fe5 | ||
|
|
b0dc0e714d | ||
|
|
01b8554c0a | ||
|
|
5e32627363 | ||
|
|
f5047f4dfa |
37
.github/workflows/docker-image.yml
vendored
37
.github/workflows/docker-image.yml
vendored
@@ -45,11 +45,37 @@ jobs:
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout (for tag resolution)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Compute VERSION for build
|
||||
run: |
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
version="${GITHUB_REF_NAME}"
|
||||
cache_variant="release"
|
||||
elif [ "${GITHUB_REF_NAME}" = "main" ] || [ "${GITHUB_REF_NAME}" = "compat" ]; then
|
||||
git fetch --tags origin main
|
||||
version="$(git describe --tags --abbrev=0 origin/main 2>/dev/null || git describe --tags --abbrev=0 main 2>/dev/null || echo v0.0.0)"
|
||||
cache_variant="${GITHUB_REF_NAME}"
|
||||
else
|
||||
version="v$(date -u +'%Y%m%d-%H%M')"
|
||||
cache_variant="nightly"
|
||||
fi
|
||||
echo "VERSION_FOR_BUILD=$version" >> $GITHUB_ENV
|
||||
echo "CACHE_VARIANT=$cache_variant" >> $GITHUB_ENV
|
||||
if [ "${GITHUB_REF_TYPE}" = "branch" ]; then
|
||||
echo "BRANCH_FOR_BUILD=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BRANCH_FOR_BUILD=" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -80,14 +106,15 @@ jobs:
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ inputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.PLATFORM_PAIR }}
|
||||
# type=gha,scope=${{ github.workflow }}-${{ env.PLATFORM_PAIR }}
|
||||
type=gha,scope=${{ github.workflow }}-${{ env.CACHE_VARIANT }}-${{ env.PLATFORM_PAIR }}
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.CACHE_VARIANT }}-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: |
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.PLATFORM_PAIR }},mode=max
|
||||
# type=gha,scope=${{ github.workflow }}-${{ env.PLATFORM_PAIR }},mode=max
|
||||
type=gha,scope=${{ github.workflow }}-${{ env.CACHE_VARIANT }}-${{ env.PLATFORM_PAIR }},mode=max
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ inputs.image_name }}:buildcache-${{ env.CACHE_VARIANT }}-${{ env.PLATFORM_PAIR }},mode=max
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
VERSION=${{ env.VERSION_FOR_BUILD }}
|
||||
MAKE_ARGS=${{ env.MAKE_ARGS }}
|
||||
BRANCH=${{ env.BRANCH_FOR_BUILD }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -40,4 +40,8 @@ tsconfig.tsbuildinfo
|
||||
|
||||
!agent.compose.yml
|
||||
!agent/pkg/**
|
||||
dev-data/
|
||||
dev-data/
|
||||
|
||||
RELEASE_NOTES.md
|
||||
CLAUDE.md
|
||||
.kilocode/**
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -7,3 +7,6 @@
|
||||
[submodule "goutils"]
|
||||
path = goutils
|
||||
url = https://github.com/yusing/goutils.git
|
||||
[submodule "internal/go-proxmox"]
|
||||
path = internal/go-proxmox
|
||||
url = https://github.com/yusing/go-proxmox
|
||||
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
yusing@6uo.me.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -14,6 +14,7 @@ WORKDIR /src
|
||||
COPY goutils/go.mod goutils/go.sum ./goutils/
|
||||
COPY internal/go-oidc/go.mod internal/go-oidc/go.sum ./internal/go-oidc/
|
||||
COPY internal/gopsutil/go.mod internal/gopsutil/go.sum ./internal/gopsutil/
|
||||
COPY internal/go-proxmox/go.mod internal/go-proxmox/go.sum ./internal/go-proxmox/
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# remove godoxy stuff from go.mod first
|
||||
@@ -43,6 +44,9 @@ ENV VERSION=${VERSION}
|
||||
ARG MAKE_ARGS
|
||||
ENV MAKE_ARGS=${MAKE_ARGS}
|
||||
|
||||
ARG BRANCH
|
||||
ENV BRANCH=${BRANCH}
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/root/go/pkg/mod \
|
||||
make ${MAKE_ARGS} docker=1 build
|
||||
|
||||
13
Makefile
13
Makefile
@@ -1,5 +1,6 @@
|
||||
shell := /bin/sh
|
||||
export VERSION ?= $(shell git describe --tags --abbrev=0)
|
||||
export BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
|
||||
export GOOS = linux
|
||||
|
||||
@@ -8,7 +9,12 @@ REPO_URL ?= https://github.com/yusing/godoxy
|
||||
WEBUI_DIR ?= ../godoxy-webui
|
||||
DOCS_DIR ?= ${WEBUI_DIR}/wiki
|
||||
|
||||
GO_TAGS = sonic
|
||||
ifneq ($(BRANCH), compat)
|
||||
GO_TAGS = sonic
|
||||
else
|
||||
GO_TAGS =
|
||||
endif
|
||||
|
||||
LDFLAGS = -X github.com/yusing/goutils/version.version=${VERSION} -checklinkname=0
|
||||
|
||||
ifeq ($(agent), 1)
|
||||
@@ -137,9 +143,6 @@ benchmark:
|
||||
dev-run: build
|
||||
cd dev-data && ${BIN_PATH}
|
||||
|
||||
mtrace:
|
||||
${BIN_PATH} debug-ls-mtrace > mtrace.json
|
||||
|
||||
rapid-crash:
|
||||
docker run --restart=always --name test_crash -p 80 debian:bookworm-slim /bin/cat &&\
|
||||
sleep 3 &&\
|
||||
@@ -156,7 +159,7 @@ cloc:
|
||||
scc -w -i go --not-match '_test.go$$'
|
||||
|
||||
push-github:
|
||||
git push origin $(shell git rev-parse --abbrev-ref HEAD)
|
||||
git push origin $(BRANCH)
|
||||
|
||||
gen-swagger:
|
||||
# go install github.com/swaggo/swag/cmd/swag@latest
|
||||
|
||||
61
README.md
61
README.md
@@ -33,6 +33,10 @@ Have questions? Ask [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e4
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Setup](#setup)
|
||||
- [How does GoDoxy work](#how-does-godoxy-work)
|
||||
- [Proxmox Integration](#proxmox-integration)
|
||||
- [Automatic Route Binding](#automatic-route-binding)
|
||||
- [WebUI Management](#webui-management)
|
||||
- [API Endpoints](#api-endpoints)
|
||||
- [Update / Uninstall system agent](#update--uninstall-system-agent)
|
||||
- [Screenshots](#screenshots)
|
||||
- [idlesleeper](#idlesleeper)
|
||||
@@ -67,7 +71,11 @@ Have questions? Ask [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e4
|
||||
- Podman
|
||||
- **Idle-sleep**: stop and wake containers based on traffic _(see [screenshots](#idlesleeper))_
|
||||
- Docker containers
|
||||
- Proxmox LXCs
|
||||
- Proxmox LXC containers
|
||||
- **Proxmox Integration**
|
||||
- **Automatic route binding**: Routes automatically bind to Proxmox nodes or LXC containers by matching hostname, IP, or alias
|
||||
- **LXC lifecycle control**: Start, stop, restart containers directly from WebUI
|
||||
- **Real-time logs**: Stream journalctl logs from nodes and LXC containers via WebSocket
|
||||
- **Traffic Management**
|
||||
- HTTP reserve proxy
|
||||
- TCP/UDP port forwarding
|
||||
@@ -80,7 +88,12 @@ Have questions? Ask [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e4
|
||||
- App Dashboard
|
||||
- Config Editor
|
||||
- Uptime and System Metrics
|
||||
- Docker Logs Viewer
|
||||
- **Docker**
|
||||
- Container lifecycle management (start, stop, restart)
|
||||
- Real-time container logs via WebSocket
|
||||
- **Proxmox**
|
||||
- LXC container lifecycle management (start, stop, restart)
|
||||
- Real-time node and LXC journalctl logs via WebSocket
|
||||
- **Cross-Platform support**
|
||||
- Supports **linux/amd64** and **linux/arm64**
|
||||
- **Efficient and Performant**
|
||||
@@ -128,6 +141,50 @@ Configure Wildcard DNS Record(s) to point to machine running `GoDoxy`, e.g.
|
||||
>
|
||||
> For example, with the label `proxy.aliases: qbt` you can access your app via `qbt.domain.com`.
|
||||
|
||||
## Proxmox Integration
|
||||
|
||||
GoDoxy can automatically discover and manage Proxmox nodes and LXC containers through configured providers.
|
||||
|
||||
### Automatic Route Binding
|
||||
|
||||
Routes are automatically linked to Proxmox resources through reverse lookup:
|
||||
|
||||
1. **Node-level routes** (VMID = 0): When hostname, IP, or alias matches a Proxmox node name or IP
|
||||
2. **Container-level routes** (VMID > 0): When hostname, IP, or alias matches an LXC container
|
||||
|
||||
This enables seamless proxy configuration without manual binding:
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
pve-node-01:
|
||||
host: pve-node-01.internal
|
||||
port: 8006
|
||||
# Automatically links to Proxmox node pve-node-01
|
||||
```
|
||||
|
||||
### WebUI Management
|
||||
|
||||
From the WebUI, you can:
|
||||
|
||||
- **LXC Lifecycle Control**: Start, stop, restart containers
|
||||
- **Node Logs**: Stream real-time journalctl output from nodes
|
||||
- **LXC Logs**: Stream real-time journalctl output from containers
|
||||
|
||||
### API Endpoints
|
||||
|
||||
```http
|
||||
# Node journalctl (WebSocket)
|
||||
GET /api/v1/proxmox/journalctl/:node
|
||||
|
||||
# LXC journalctl (WebSocket)
|
||||
GET /api/v1/proxmox/journalctl/:node/:vmid
|
||||
|
||||
# LXC lifecycle control
|
||||
POST /api/v1/proxmox/lxc/:node/:vmid/start
|
||||
POST /api/v1/proxmox/lxc/:node/:vmid/stop
|
||||
POST /api/v1/proxmox/lxc/:node/:vmid/restart
|
||||
```
|
||||
|
||||
## Update / Uninstall system agent
|
||||
|
||||
Update:
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
- [安裝](#安裝)
|
||||
- [手動安裝](#手動安裝)
|
||||
- [資料夾結構](#資料夾結構)
|
||||
- [Proxmox 整合](#proxmox-整合)
|
||||
- [自動路由綁定](#自動路由綁定)
|
||||
- [WebUI 管理](#webui-管理)
|
||||
- [API 端點](#api-端點)
|
||||
- [更新 / 卸載系統代理 (System Agent)](#更新--卸載系統代理-system-agent)
|
||||
- [截圖](#截圖)
|
||||
- [閒置休眠](#閒置休眠)
|
||||
@@ -67,6 +71,10 @@
|
||||
- **閒置休眠**:根據流量停止和喚醒容器 _(參見[截圖](#閒置休眠))_
|
||||
- Docker 容器
|
||||
- Proxmox LXC 容器
|
||||
- **Proxmox 整合**
|
||||
- **自動路由綁定**:透過比對主機名稱、IP 或別名自動將路由綁定至 Proxmox 節點或 LXC 容器
|
||||
- **LXC 生命週期控制**:可直接從 WebUI 啟動、停止、重新啟動容器
|
||||
- **即時日誌**:透過 WebSocket 串流節點和 LXC 容器的 journalctl 日誌
|
||||
- **流量管理**
|
||||
- HTTP 反向代理
|
||||
- TCP/UDP 連接埠轉送
|
||||
@@ -79,7 +87,12 @@
|
||||
- 應用程式一覽
|
||||
- 設定編輯器
|
||||
- 執行時間與系統指標
|
||||
- Docker 日誌檢視器
|
||||
- **Docker**
|
||||
- 容器生命週期管理 (啟動、停止、重新啟動)
|
||||
- 透過 WebSocket 即時串流容器日誌
|
||||
- **Proxmox**
|
||||
- LXC 容器生命週期管理 (啟動、停止、重新啟動)
|
||||
- 透過 WebSocket 即時串流節點和 LXC 容器 journalctl 日誌
|
||||
- **跨平台支援**
|
||||
- 支援 **linux/amd64** 與 **linux/arm64**
|
||||
- **高效能**
|
||||
@@ -144,6 +157,50 @@
|
||||
└── .env
|
||||
```
|
||||
|
||||
## Proxmox 整合
|
||||
|
||||
GoDoxy 可透過配置的提供者自動探索和管理 Proxmox 節點和 LXC 容器。
|
||||
|
||||
### 自動路由綁定
|
||||
|
||||
路由透過反向查詢自動連結至 Proxmox 資源:
|
||||
|
||||
1. **節點級路由** (VMID = 0):當主機名稱、IP 或別名符合 Proxmox 節點名稱或 IP 時
|
||||
2. **容器級路由** (VMID > 0):當主機名稱、IP 或別名符合 LXC 容器時
|
||||
|
||||
這可實現無需手動綁定的無縫代理配置:
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
pve-node-01:
|
||||
host: pve-node-01.internal
|
||||
port: 8006
|
||||
# 自動連結至 Proxmox 節點 pve-node-01
|
||||
```
|
||||
|
||||
### WebUI 管理
|
||||
|
||||
您可以從 WebUI:
|
||||
|
||||
- **LXC 生命週期控制**:啟動、停止、重新啟動容器
|
||||
- **節點日誌**:串流來自節點的即時 journalctl 輸出
|
||||
- **LXC 日誌**:串流來自容器的即時 journalctl 輸出
|
||||
|
||||
### API 端點
|
||||
|
||||
```http
|
||||
# 節點 journalctl (WebSocket)
|
||||
GET /api/v1/proxmox/journalctl/:node
|
||||
|
||||
# LXC journalctl (WebSocket)
|
||||
GET /api/v1/proxmox/journalctl/:node/:vmid
|
||||
|
||||
# LXC 生命週期控制
|
||||
POST /api/v1/proxmox/lxc/:node/:vmid/start
|
||||
POST /api/v1/proxmox/lxc/:node/:vmid/stop
|
||||
POST /api/v1/proxmox/lxc/:node/:vmid/restart
|
||||
```
|
||||
|
||||
## 更新 / 卸載系統代理 (System Agent)
|
||||
|
||||
更新:
|
||||
|
||||
14
agent/go.mod
14
agent/go.mod
@@ -15,14 +15,14 @@ replace (
|
||||
exclude github.com/containerd/nerdctl/mod/tigron v0.0.0
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.2
|
||||
github.com/bytedance/sonic v1.15.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/pion/dtls/v3 v3.0.10
|
||||
github.com/pion/transport/v3 v3.1.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/yusing/godoxy v0.24.1
|
||||
github.com/yusing/godoxy v0.25.0
|
||||
github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000
|
||||
github.com/yusing/goutils v0.7.0
|
||||
)
|
||||
@@ -31,14 +31,14 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/cli v29.1.4+incompatible // indirect
|
||||
github.com/docker/cli v29.1.5+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
@@ -73,7 +73,7 @@ require (
|
||||
github.com/pion/transport/v4 v4.0.1 // 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/puzpuzpuz/xsync/v4 v4.3.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.12 // indirect
|
||||
@@ -86,8 +86,8 @@ require (
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/yusing/ds v0.4.1 // indirect
|
||||
github.com/yusing/gointernals v0.1.16 // indirect
|
||||
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260116021320-b12ef77f3743 // indirect
|
||||
github.com/yusing/goutils/http/websocket v0.0.0-20260116021320-b12ef77f3743 // indirect
|
||||
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260125040745-bcc4b498f878 // indirect
|
||||
github.com/yusing/goutils/http/websocket v0.0.0-20260125040745-bcc4b498f878 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
|
||||
|
||||
24
agent/go.sum
24
agent/go.sum
@@ -10,10 +10,10 @@ github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
|
||||
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -37,8 +37,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/docker/cli v29.1.4+incompatible h1:AI8fwZhqsAsrqZnVv9h6lbexeW/LzNTasf6A4vcNN8M=
|
||||
github.com/docker/cli v29.1.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.1.5+incompatible h1:GckbANUt3j+lsnQ6eCcQd70mNSOismSHWt8vk2AX8ao=
|
||||
github.com/docker/cli v29.1.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
@@ -124,8 +124,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
|
||||
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
|
||||
@@ -153,16 +153,16 @@ github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkY
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
||||
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pires/go-proxyproto v0.9.1 h1:wTPjpyk41pJm1Im9BqHtPLuhxfjxL+qNfSikx9ux0WY=
|
||||
github.com/pires/go-proxyproto v0.9.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/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/puzpuzpuz/xsync/v4 v4.3.0 h1:w/bWkEJdYuRNYhHn5eXnIT8LzDM1O629X1I9MJSkD7Q=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
|
||||
24
cmd/main.go
24
cmd/main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/godoxy/internal/api"
|
||||
@@ -32,6 +33,16 @@ func parallel(fns ...func()) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
done := make(chan struct{}, 1)
|
||||
go func() {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-time.After(common.InitTimeout):
|
||||
log.Fatal().Msgf("timeout waiting for initialization to complete, exiting...")
|
||||
}
|
||||
}()
|
||||
|
||||
initProfiling()
|
||||
|
||||
logging.InitLogger(os.Stderr, memlogger.GetMemLogger())
|
||||
@@ -69,14 +80,25 @@ func main() {
|
||||
server.StartServer(task.RootTask("api_server", false), server.Options{
|
||||
Name: "api",
|
||||
HTTPAddr: common.APIHTTPAddr,
|
||||
Handler: api.NewHandler(),
|
||||
Handler: api.NewHandler(true),
|
||||
})
|
||||
|
||||
// Local API Handler is used for unauthenticated access.
|
||||
if common.LocalAPIHTTPAddr != "" {
|
||||
server.StartServer(task.RootTask("local_api_server", false), server.Options{
|
||||
Name: "local_api",
|
||||
HTTPAddr: common.LocalAPIHTTPAddr,
|
||||
Handler: api.NewHandler(false),
|
||||
})
|
||||
}
|
||||
|
||||
listenDebugServer()
|
||||
|
||||
uptime.Poller.Start()
|
||||
config.WatchChanges()
|
||||
|
||||
close(done)
|
||||
|
||||
task.WaitExit(config.Value().TimeoutShutdown)
|
||||
}
|
||||
|
||||
|
||||
33
go.mod
33
go.mod
@@ -4,6 +4,7 @@ go 1.25.6
|
||||
|
||||
replace (
|
||||
github.com/coreos/go-oidc/v3 => ./internal/go-oidc
|
||||
github.com/luthermonson/go-proxmox => ./internal/go-proxmox
|
||||
github.com/shirou/gopsutil/v4 => ./internal/gopsutil
|
||||
github.com/yusing/godoxy/agent => ./agent
|
||||
github.com/yusing/godoxy/internal/dnsproviders => ./internal/dnsproviders
|
||||
@@ -24,8 +25,8 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3 // websocket for API and agent
|
||||
github.com/gotify/server/v2 v2.8.0 // reference the Message struct for json response
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // fuzzy search for searching icons and filtering metrics
|
||||
github.com/pires/go-proxyproto v0.8.1 // proxy protocol support
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 // lock free map for concurrent operations
|
||||
github.com/pires/go-proxyproto v0.9.1 // proxy protocol support
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 // lock free map for concurrent operations
|
||||
github.com/rs/zerolog v1.34.0 // logging
|
||||
github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon
|
||||
golang.org/x/crypto v0.47.0 // encrypting password with bcrypt
|
||||
@@ -37,8 +38,8 @@ require (
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // xxhash64 for fast hash
|
||||
github.com/bytedance/sonic v1.14.2 // fast json parsing
|
||||
github.com/docker/cli v29.1.4+incompatible // needs docker/cli/cli/connhelper connection helper for docker client
|
||||
github.com/bytedance/sonic v1.15.0 // fast json parsing
|
||||
github.com/docker/cli v29.1.5+incompatible // needs docker/cli/cli/connhelper connection helper for docker client
|
||||
github.com/goccy/go-yaml v1.19.2 // yaml parsing for different config files
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // jwt authentication
|
||||
github.com/luthermonson/go-proxmox v0.3.2 // proxmox API client
|
||||
@@ -51,17 +52,17 @@ require (
|
||||
github.com/stretchr/testify v1.11.1 // testing framework
|
||||
github.com/valyala/fasthttp v1.69.0 // fast http for health check
|
||||
github.com/yusing/ds v0.4.1 // data structures and algorithms
|
||||
github.com/yusing/godoxy/agent v0.0.0-20260116020954-edcde00dcc3a
|
||||
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260116020954-edcde00dcc3a
|
||||
github.com/yusing/godoxy/agent v0.0.0-20260125091326-9c2051840fd9
|
||||
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260124133347-9a96f3cc539e
|
||||
github.com/yusing/gointernals v0.1.16
|
||||
github.com/yusing/goutils v0.7.0
|
||||
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260116021320-b12ef77f3743
|
||||
github.com/yusing/goutils/http/websocket v0.0.0-20260116021320-b12ef77f3743
|
||||
github.com/yusing/goutils/server v0.0.0-20260116021320-b12ef77f3743
|
||||
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260125040745-bcc4b498f878
|
||||
github.com/yusing/goutils/http/websocket v0.0.0-20260125040745-bcc4b498f878
|
||||
github.com/yusing/goutils/server v0.0.0-20260125040745-bcc4b498f878
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.18.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
@@ -103,7 +104,7 @@ require (
|
||||
github.com/magefile/mage v1.15.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/miekg/dns v1.1.70 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
@@ -135,8 +136,8 @@ require (
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/api v0.260.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/api v0.262.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.1 // indirect
|
||||
@@ -148,7 +149,7 @@ require (
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
@@ -169,8 +170,8 @@ require (
|
||||
github.com/linode/linodego v1.64.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/nrdcg/goinwx v0.12.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
|
||||
46
go.sum
46
go.sum
@@ -1,5 +1,5 @@
|
||||
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
|
||||
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
|
||||
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
@@ -51,10 +51,10 @@ github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
|
||||
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -76,8 +76,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/docker/cli v29.1.4+incompatible h1:AI8fwZhqsAsrqZnVv9h6lbexeW/LzNTasf6A4vcNN8M=
|
||||
github.com/docker/cli v29.1.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.1.5+incompatible h1:GckbANUt3j+lsnQ6eCcQd70mNSOismSHWt8vk2AX8ao=
|
||||
github.com/docker/cli v29.1.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
@@ -197,8 +197,6 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/luthermonson/go-proxmox v0.3.2 h1:/zUg6FCl9cAABx0xU3OIgtDtClY0gVXxOCsrceDNylc=
|
||||
github.com/luthermonson/go-proxmox v0.3.2/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
|
||||
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
|
||||
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -210,8 +208,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
|
||||
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
|
||||
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
|
||||
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -229,10 +227,10 @@ github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0
|
||||
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
|
||||
github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
|
||||
github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.0 h1:4MRzV6spwPHKct+4/ETqkEtr39Hq+0KvxhsgqbgQ2Bo=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.0/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.0 h1:RxraLVYX3eMUfQ1pDtJVvykEFGheky2YsrUt2HHRDcw=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.0/go.mod h1:JLMEKMX8IYPZ1TUSVHAVAbtnNSfP/I8OZQkAnfEMA0I=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1 h1:+fx2mbWeR8XX/vidwpRMepJMtRIYQP44Iezm2oeObVM=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1 h1:GDhBiaIAm/QXLzHJ0ASDdY/6R/9w60+gk8lY5rgfxEQ=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1/go.mod h1:EHScJdbM0gg5Is7e3C0ceRYAFMMsfP4Vf8sBRoxoTgk=
|
||||
github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
|
||||
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -253,8 +251,8 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
||||
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pires/go-proxyproto v0.9.1 h1:wTPjpyk41pJm1Im9BqHtPLuhxfjxL+qNfSikx9ux0WY=
|
||||
github.com/pires/go-proxyproto v0.9.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -267,8 +265,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 h1:w/bWkEJdYuRNYhHn5eXnIT8LzDM1O629X1I9MJSkD7Q=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -449,14 +447,14 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4=
|
||||
google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o=
|
||||
google.golang.org/api v0.262.0 h1:4B+3u8He2GwyN8St3Jhnd3XRHlIvc//sBmgHSp78oNY=
|
||||
google.golang.org/api v0.262.0/go.mod h1:jNwmH8BgUBJ/VrUG6/lIl9YiildyLd09r9ZLHiQ6cGI=
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
2
goutils
2
goutils
Submodule goutils updated: 92af8e0866...0fcd628370
@@ -26,9 +26,9 @@ type Config struct {
|
||||
Log *accesslog.ACLLoggerConfig `json:"log"`
|
||||
|
||||
Notify struct {
|
||||
To []string `json:"to"` // list of notification providers
|
||||
Interval time.Duration `json:"interval"` // interval between notifications
|
||||
IncludeAllowed *bool `json:"include_allowed"` // default: false
|
||||
To []string `json:"to,omitempty"` // list of notification providers
|
||||
Interval time.Duration `json:"interval,omitempty"` // interval between notifications
|
||||
IncludeAllowed *bool `json:"include_allowed,omitzero"` // default: false
|
||||
} `json:"notify"`
|
||||
|
||||
config
|
||||
@@ -106,7 +106,7 @@ func (c *Config) Validate() gperr.Error {
|
||||
c.allowLocal = true
|
||||
}
|
||||
|
||||
if c.Notify.Interval < 0 {
|
||||
if c.Notify.Interval <= 0 {
|
||||
c.Notify.Interval = defaultNotifyInterval
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
@@ -12,6 +13,7 @@ type MatcherFunc func(*maxmind.IPInfo) bool
|
||||
|
||||
type Matcher struct {
|
||||
match MatcherFunc
|
||||
raw string
|
||||
}
|
||||
|
||||
type Matchers []Matcher
|
||||
@@ -46,6 +48,7 @@ func (matcher *Matcher) Parse(s string) error {
|
||||
if len(parts) != 2 {
|
||||
return errSyntax
|
||||
}
|
||||
matcher.raw = s
|
||||
|
||||
switch parts[0] {
|
||||
case MatcherTypeIP:
|
||||
@@ -79,6 +82,18 @@ func (matchers Matchers) Match(ip *maxmind.IPInfo) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (matchers Matchers) MarshalText() ([]byte, error) {
|
||||
if len(matchers) == 0 {
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
for _, m := range matchers {
|
||||
buf.WriteString(m.raw)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func matchIP(ip net.IP) MatcherFunc {
|
||||
return func(ip2 *maxmind.IPInfo) bool {
|
||||
return ip.Equal(ip2.IP)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
fileApi "github.com/yusing/godoxy/internal/api/v1/file"
|
||||
homepageApi "github.com/yusing/godoxy/internal/api/v1/homepage"
|
||||
metricsApi "github.com/yusing/godoxy/internal/api/v1/metrics"
|
||||
proxmoxApi "github.com/yusing/godoxy/internal/api/v1/proxmox"
|
||||
routeApi "github.com/yusing/godoxy/internal/api/v1/route"
|
||||
"github.com/yusing/godoxy/internal/auth"
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
@@ -38,7 +39,7 @@ import (
|
||||
|
||||
// @externalDocs.description GoDoxy Docs
|
||||
// @externalDocs.url https://docs.godoxy.dev
|
||||
func NewHandler() *gin.Engine {
|
||||
func NewHandler(requireAuth bool) *gin.Engine {
|
||||
if !common.IsDebug {
|
||||
gin.SetMode("release")
|
||||
}
|
||||
@@ -51,7 +52,7 @@ func NewHandler() *gin.Engine {
|
||||
|
||||
r.GET("/api/v1/version", apiV1.Version)
|
||||
|
||||
if auth.IsEnabled() {
|
||||
if auth.IsEnabled() && requireAuth {
|
||||
v1Auth := r.Group("/api/v1/auth")
|
||||
{
|
||||
v1Auth.HEAD("/check", authApi.Check)
|
||||
@@ -64,7 +65,7 @@ func NewHandler() *gin.Engine {
|
||||
}
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
if auth.IsEnabled() {
|
||||
if auth.IsEnabled() && requireAuth {
|
||||
v1.Use(AuthMiddleware())
|
||||
}
|
||||
if common.APISkipOriginCheck {
|
||||
@@ -140,6 +141,21 @@ func NewHandler() *gin.Engine {
|
||||
docker.POST("/start", dockerApi.Start)
|
||||
docker.POST("/stop", dockerApi.Stop)
|
||||
docker.POST("/restart", dockerApi.Restart)
|
||||
docker.GET("/stats/:id", dockerApi.Stats)
|
||||
}
|
||||
|
||||
proxmox := v1.Group("/proxmox")
|
||||
{
|
||||
proxmox.GET("/tail", proxmoxApi.Tail)
|
||||
proxmox.GET("/journalctl", proxmoxApi.Journalctl)
|
||||
proxmox.GET("/journalctl/:node", proxmoxApi.Journalctl)
|
||||
proxmox.GET("/journalctl/:node/:vmid", proxmoxApi.Journalctl)
|
||||
proxmox.GET("/journalctl/:node/:vmid/:service", proxmoxApi.Journalctl)
|
||||
proxmox.GET("/stats/:node", proxmoxApi.NodeStats)
|
||||
proxmox.GET("/stats/:node/:vmid", proxmoxApi.VMStats)
|
||||
proxmox.POST("/lxc/:node/:vmid/start", proxmoxApi.Start)
|
||||
proxmox.POST("/lxc/:node/:vmid/stop", proxmoxApi.Stop)
|
||||
proxmox.POST("/lxc/:node/:vmid/restart", proxmoxApi.Restart)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ Types are defined in `goutils/apitypes`:
|
||||
| `file` | Configuration file read/write operations |
|
||||
| `auth` | Authentication and session management |
|
||||
| `agent` | Remote agent creation and management |
|
||||
| `proxmox` | Proxmox API management and monitoring |
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -77,15 +78,16 @@ API listening address is configured with `GODOXY_API_ADDR` environment variable.
|
||||
|
||||
### Internal Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
| ----------------------- | --------------------------- |
|
||||
| `internal/route/routes` | Route storage and iteration |
|
||||
| `internal/docker` | Docker client management |
|
||||
| `internal/config` | Configuration access |
|
||||
| `internal/metrics` | System metrics collection |
|
||||
| `internal/homepage` | Homepage item generation |
|
||||
| `internal/agentpool` | Remote agent management |
|
||||
| `internal/auth` | Authentication services |
|
||||
| Package | Purpose |
|
||||
| ----------------------- | ------------------------------------- |
|
||||
| `internal/route/routes` | Route storage and iteration |
|
||||
| `internal/docker` | Docker client management |
|
||||
| `internal/config` | Configuration access |
|
||||
| `internal/metrics` | System metrics collection |
|
||||
| `internal/homepage` | Homepage item generation |
|
||||
| `internal/agentpool` | Remote agent management |
|
||||
| `internal/auth` | Authentication services |
|
||||
| `internal/proxmox` | Proxmox API management and monitoring |
|
||||
|
||||
### External Dependencies
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
@@ -22,6 +23,7 @@ type LogsQueryParams struct {
|
||||
Since string `form:"from"`
|
||||
Until string `form:"to"`
|
||||
Levels string `form:"levels"`
|
||||
Limit int `form:"limit,default=100" binding:"min=1,max=1000"`
|
||||
} // @name LogsQueryParams
|
||||
|
||||
// @x-id "logs"
|
||||
@@ -34,9 +36,10 @@ type LogsQueryParams struct {
|
||||
// @Param id path string true "container id"
|
||||
// @Param stdout query bool false "show stdout"
|
||||
// @Param stderr query bool false "show stderr"
|
||||
// @Param from query string false "from timestamp"
|
||||
// @Param to query string false "to timestamp"
|
||||
// @Param from query string false "from timestamp"
|
||||
// @Param to query string false "to timestamp"
|
||||
// @Param levels query string false "levels"
|
||||
// @Param limit query int false "limit"
|
||||
// @Success 200
|
||||
// @Failure 400 {object} apitypes.ErrorResponse
|
||||
// @Failure 403 {object} apitypes.ErrorResponse
|
||||
@@ -77,7 +80,7 @@ func Logs(c *gin.Context) {
|
||||
Until: queryParams.Until,
|
||||
Timestamps: true,
|
||||
Follow: true,
|
||||
Tail: "100",
|
||||
Tail: strconv.Itoa(queryParams.Limit),
|
||||
}
|
||||
if queryParams.Levels != "" {
|
||||
opts.Details = true
|
||||
|
||||
117
internal/api/v1/docker/stats.go
Normal file
117
internal/api/v1/docker/stats.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package dockerapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/yusing/godoxy/internal/docker"
|
||||
"github.com/yusing/godoxy/internal/route/routes"
|
||||
"github.com/yusing/godoxy/internal/types"
|
||||
apitypes "github.com/yusing/goutils/apitypes"
|
||||
"github.com/yusing/goutils/http/httpheaders"
|
||||
"github.com/yusing/goutils/http/websocket"
|
||||
"github.com/yusing/goutils/synk"
|
||||
"github.com/yusing/goutils/task"
|
||||
)
|
||||
|
||||
type ContainerStatsResponse container.StatsResponse // @name ContainerStatsResponse
|
||||
|
||||
// @x-id "stats"
|
||||
// @BasePath /api/v1
|
||||
// @Summary Get container stats
|
||||
// @Description Get container stats by container id
|
||||
// @Tags docker,websocket
|
||||
// @Produce json
|
||||
// @Param id path string true "Container ID or route alias"
|
||||
// @Success 200 {object} ContainerStatsResponse
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request: id is required or route is not a docker container"
|
||||
// @Failure 403 {object} apitypes.ErrorResponse
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Container not found"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse
|
||||
// @Router /docker/stats/{id} [get]
|
||||
func Stats(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
dockerCfg, ok := docker.GetDockerCfgByContainerID(id)
|
||||
if !ok {
|
||||
var route types.Route
|
||||
route, ok = routes.GetIncludeExcluded(id)
|
||||
if ok {
|
||||
cont := route.ContainerInfo()
|
||||
if cont == nil {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("route is not a docker container"))
|
||||
return
|
||||
}
|
||||
dockerCfg = cont.DockerCfg
|
||||
id = cont.ContainerID
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("container or route not found"))
|
||||
return
|
||||
}
|
||||
|
||||
dockerClient, err := docker.NewClient(dockerCfg)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to create docker client"))
|
||||
return
|
||||
}
|
||||
defer dockerClient.Close()
|
||||
|
||||
if httpheaders.IsWebsocket(c.Request.Header) {
|
||||
stats, err := dockerClient.ContainerStats(c.Request.Context(), id, client.ContainerStatsOptions{Stream: true})
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to get container stats"))
|
||||
return
|
||||
}
|
||||
defer stats.Body.Close()
|
||||
|
||||
manager, err := websocket.NewManagerWithUpgrade(c)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to create websocket manager"))
|
||||
return
|
||||
}
|
||||
defer manager.Close()
|
||||
|
||||
buf := synk.GetSizedBytesPool().GetSized(4096)
|
||||
defer synk.GetSizedBytesPool().Put(buf)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-manager.Done():
|
||||
return
|
||||
default:
|
||||
_, err = io.CopyBuffer(manager.NewWriter(websocket.TextMessage), stats.Body, buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, task.ErrProgramExiting) {
|
||||
return
|
||||
}
|
||||
c.Error(apitypes.InternalServerError(err, "failed to copy container stats"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := dockerClient.ContainerStats(c.Request.Context(), id, client.ContainerStatsOptions{Stream: false})
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to get container stats"))
|
||||
return
|
||||
}
|
||||
defer stats.Body.Close()
|
||||
|
||||
_, err = io.Copy(c.Writer, stats.Body)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to copy container stats"))
|
||||
return
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ type ValidateFileRequest struct {
|
||||
// @Summary Validate file
|
||||
// @Description Validate file
|
||||
// @Tags file
|
||||
// @Accept text/plain
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param type query FileType true "Type"
|
||||
// @Param file body string true "File content"
|
||||
@@ -29,7 +29,7 @@ type ValidateFileRequest struct {
|
||||
// @Failure 403 {object} apitypes.ErrorResponse "Forbidden"
|
||||
// @Failure 417 {object} any "Validation failed"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
|
||||
// @Router /file/validate [post]
|
||||
// @Router /file/validate [post]
|
||||
func Validate(c *gin.Context) {
|
||||
var request ValidateFileRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
|
||||
6
internal/api/v1/proxmox/common.go
Normal file
6
internal/api/v1/proxmox/common.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package proxmoxapi
|
||||
|
||||
type ActionRequest struct {
|
||||
Node string `uri:"node" binding:"required"`
|
||||
VMID int `uri:"vmid" binding:"required"`
|
||||
} // @name ProxmoxVMActionRequest
|
||||
85
internal/api/v1/proxmox/journalctl.go
Normal file
85
internal/api/v1/proxmox/journalctl.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package proxmoxapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yusing/godoxy/internal/proxmox"
|
||||
"github.com/yusing/goutils/apitypes"
|
||||
"github.com/yusing/goutils/http/websocket"
|
||||
)
|
||||
|
||||
// e.g. ws://localhost:8889/api/v1/proxmox/journalctl?node=pve&vmid=127&service=pveproxy&service=pvedaemon&limit=10
|
||||
// e.g. ws://localhost:8889/api/v1/proxmox/journalctl/pve/127?service=pveproxy&service=pvedaemon&limit=10
|
||||
|
||||
type JournalctlRequest struct {
|
||||
Node string `form:"node" uri:"node" binding:"required"` // Node name
|
||||
VMID *int `form:"vmid" uri:"vmid"` // Container VMID (optional - if not provided, streams node journalctl)
|
||||
Services []string `form:"service" uri:"service"` // Service names
|
||||
Limit *int `form:"limit" uri:"limit" default:"100" binding:"min=1,max=1000"` // Limit output lines (1-1000)
|
||||
} // @name ProxmoxJournalctlRequest
|
||||
|
||||
// @x-id "journalctl"
|
||||
// @BasePath /api/v1
|
||||
// @Summary Get journalctl output
|
||||
// @Description Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.
|
||||
// @Tags proxmox,websocket
|
||||
// @Accept json
|
||||
// @Produce application/json
|
||||
// @Param query query JournalctlRequest true "Request"
|
||||
// @Param path path JournalctlRequest true "Request"
|
||||
// @Success 200 string plain "Journalctl output"
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
|
||||
// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized"
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
|
||||
// @Router /proxmox/journalctl [get]
|
||||
// @Router /proxmox/journalctl/{node} [get]
|
||||
// @Router /proxmox/journalctl/{node}/{vmid} [get]
|
||||
// @Router /proxmox/journalctl/{node}/{vmid}/{service} [get]
|
||||
func Journalctl(c *gin.Context) {
|
||||
var request JournalctlRequest
|
||||
uriErr := c.ShouldBindUri(&request)
|
||||
queryErr := c.ShouldBindQuery(&request)
|
||||
if uriErr != nil && queryErr != nil { // allow both uri and query parameters to be set
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", errors.Join(uriErr, queryErr)))
|
||||
return
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(request.Node)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusContinue)
|
||||
|
||||
var reader io.ReadCloser
|
||||
var err error
|
||||
if request.VMID == nil {
|
||||
reader, err = node.NodeJournalctl(c.Request.Context(), request.Services, *request.Limit)
|
||||
} else {
|
||||
reader, err = node.LXCJournalctl(c.Request.Context(), *request.VMID, request.Services, *request.Limit)
|
||||
}
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to get journalctl output"))
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
manager, err := websocket.NewManagerWithUpgrade(c)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
|
||||
return
|
||||
}
|
||||
defer manager.Close()
|
||||
|
||||
writer := manager.NewWriter(websocket.TextMessage)
|
||||
_, err = io.Copy(writer, reader)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to copy journalctl output"))
|
||||
return
|
||||
}
|
||||
}
|
||||
42
internal/api/v1/proxmox/restart.go
Normal file
42
internal/api/v1/proxmox/restart.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package proxmoxapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yusing/godoxy/internal/proxmox"
|
||||
apitypes "github.com/yusing/goutils/apitypes"
|
||||
)
|
||||
|
||||
// @x-id "lxcRestart"
|
||||
// @BasePath /api/v1
|
||||
// @Summary Restart LXC container
|
||||
// @Description Restart LXC container by node and vmid
|
||||
// @Tags proxmox
|
||||
// @Produce json
|
||||
// @Param path path ActionRequest true "Request"
|
||||
// @Success 200 {object} apitypes.SuccessResponse
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse
|
||||
// @Router /proxmox/lxc/:node/:vmid/restart [post]
|
||||
func Restart(c *gin.Context) {
|
||||
var req ActionRequest
|
||||
if err := c.ShouldBindUri(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
|
||||
return
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(req.Node)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := node.LXCAction(c.Request.Context(), req.VMID, proxmox.LXCReboot); err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to restart container"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apitypes.Success("container restarted"))
|
||||
}
|
||||
42
internal/api/v1/proxmox/start.go
Normal file
42
internal/api/v1/proxmox/start.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package proxmoxapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yusing/godoxy/internal/proxmox"
|
||||
apitypes "github.com/yusing/goutils/apitypes"
|
||||
)
|
||||
|
||||
// @x-id "lxcStart"
|
||||
// @BasePath /api/v1
|
||||
// @Summary Start LXC container
|
||||
// @Description Start LXC container by node and vmid
|
||||
// @Tags proxmox
|
||||
// @Produce json
|
||||
// @Param path path ActionRequest true "Request"
|
||||
// @Success 200 {object} apitypes.SuccessResponse
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse
|
||||
// @Router /proxmox/lxc/:node/:vmid/start [post]
|
||||
func Start(c *gin.Context) {
|
||||
var req ActionRequest
|
||||
if err := c.ShouldBindUri(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
|
||||
return
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(req.Node)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := node.LXCAction(c.Request.Context(), req.VMID, proxmox.LXCStart); err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to start container"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apitypes.Success("container started"))
|
||||
}
|
||||
139
internal/api/v1/proxmox/stats.go
Normal file
139
internal/api/v1/proxmox/stats.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package proxmoxapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yusing/godoxy/internal/proxmox"
|
||||
"github.com/yusing/goutils/apitypes"
|
||||
"github.com/yusing/goutils/http/httpheaders"
|
||||
"github.com/yusing/goutils/http/websocket"
|
||||
)
|
||||
|
||||
type StatsRequest struct {
|
||||
Node string `uri:"node" binding:"required"`
|
||||
VMID int `uri:"vmid" binding:"required"`
|
||||
}
|
||||
|
||||
// @x-id "nodeStats"
|
||||
// @BasePath /api/v1
|
||||
// @Summary Get proxmox node stats
|
||||
// @Description Get proxmox node stats in json
|
||||
// @Tags proxmox,websocket
|
||||
// @Produce application/json
|
||||
// @Param node path string true "Node name"
|
||||
// @Success 200 {object} proxmox.NodeStats "Stats output"
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
|
||||
// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized"
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
|
||||
// @Router /proxmox/stats/{node} [get]
|
||||
func NodeStats(c *gin.Context) {
|
||||
nodeName := c.Param("node")
|
||||
if nodeName == "" {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("node name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(nodeName)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
isWs := httpheaders.IsWebsocket(c.Request.Header)
|
||||
|
||||
reader, err := node.NodeStats(c.Request.Context(), isWs)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to get stats"))
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
if !isWs {
|
||||
var line [512]byte
|
||||
n, err := reader.Read(line[:])
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to copy stats"))
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", line[:n])
|
||||
return
|
||||
}
|
||||
|
||||
manager, err := websocket.NewManagerWithUpgrade(c)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
|
||||
return
|
||||
}
|
||||
defer manager.Close()
|
||||
|
||||
writer := manager.NewWriter(websocket.TextMessage)
|
||||
_, err = io.Copy(writer, reader)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to copy stats"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// @x-id "vmStats"
|
||||
// @BasePath /api/v1
|
||||
// @Summary Get proxmox VM stats
|
||||
// @Description Get proxmox VM stats in format of "STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O"
|
||||
// @Tags proxmox,websocket
|
||||
// @Produce text/plain
|
||||
// @Param path path StatsRequest true "Request"
|
||||
// @Success 200 string plain "Stats output"
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
|
||||
// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized"
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
|
||||
// @Router /proxmox/stats/{node}/{vmid} [get]
|
||||
func VMStats(c *gin.Context) {
|
||||
var request StatsRequest
|
||||
if err := c.ShouldBindUri(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
|
||||
return
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(request.Node)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
isWs := httpheaders.IsWebsocket(c.Request.Header)
|
||||
|
||||
reader, err := node.LXCStats(c.Request.Context(), request.VMID, isWs)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to get stats"))
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
if !isWs {
|
||||
var line [128]byte
|
||||
n, err := reader.Read(line[:])
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to copy stats"))
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", line[:n])
|
||||
return
|
||||
}
|
||||
|
||||
manager, err := websocket.NewManagerWithUpgrade(c)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
|
||||
return
|
||||
}
|
||||
defer manager.Close()
|
||||
|
||||
writer := manager.NewWriter(websocket.TextMessage)
|
||||
_, err = io.Copy(writer, reader)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to copy stats"))
|
||||
return
|
||||
}
|
||||
}
|
||||
42
internal/api/v1/proxmox/stop.go
Normal file
42
internal/api/v1/proxmox/stop.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package proxmoxapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yusing/godoxy/internal/proxmox"
|
||||
apitypes "github.com/yusing/goutils/apitypes"
|
||||
)
|
||||
|
||||
// @x-id "lxcStop"
|
||||
// @BasePath /api/v1
|
||||
// @Summary Stop LXC container
|
||||
// @Description Stop LXC container by node and vmid
|
||||
// @Tags proxmox
|
||||
// @Produce json
|
||||
// @Param path path ActionRequest true "Request"
|
||||
// @Success 200 {object} apitypes.SuccessResponse
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse
|
||||
// @Router /proxmox/lxc/:node/:vmid/stop [post]
|
||||
func Stop(c *gin.Context) {
|
||||
var req ActionRequest
|
||||
if err := c.ShouldBindUri(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
|
||||
return
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(req.Node)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := node.LXCAction(c.Request.Context(), req.VMID, proxmox.LXCShutdown); err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to stop container"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apitypes.Success("container stopped"))
|
||||
}
|
||||
77
internal/api/v1/proxmox/tail.go
Normal file
77
internal/api/v1/proxmox/tail.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package proxmoxapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yusing/godoxy/internal/proxmox"
|
||||
"github.com/yusing/goutils/apitypes"
|
||||
"github.com/yusing/goutils/http/websocket"
|
||||
)
|
||||
|
||||
// e.g. ws://localhost:8889/api/v1/proxmox/tail?node=pve&vmid=127&file=/var/log/immich/web.log&file=/var/log/immich/ml.log&limit=10
|
||||
|
||||
type TailRequest struct {
|
||||
Node string `form:"node" binding:"required"` // Node name
|
||||
VMID *int `form:"vmid"` // Container VMID (optional - if not provided, streams node journalctl)
|
||||
Files []string `form:"file" binding:"required,dive,filepath"` // File paths
|
||||
Limit int `form:"limit" default:"100" binding:"min=1,max=1000"` // Limit output lines (1-1000)
|
||||
} // @name ProxmoxTailRequest
|
||||
|
||||
// @x-id "tail"
|
||||
// @BasePath /api/v1
|
||||
// @Summary Get tail output
|
||||
// @Description Get tail output for node or LXC container. If vmid is not provided, streams node tail.
|
||||
// @Tags proxmox,websocket
|
||||
// @Accept json
|
||||
// @Produce application/json
|
||||
// @Param query query TailRequest true "Request"
|
||||
// @Success 200 string plain "Tail output"
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
|
||||
// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized"
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
|
||||
// @Router /proxmox/tail [get]
|
||||
func Tail(c *gin.Context) {
|
||||
var request TailRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
|
||||
return
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(request.Node)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusContinue)
|
||||
|
||||
var reader io.ReadCloser
|
||||
var err error
|
||||
if request.VMID == nil {
|
||||
reader, err = node.NodeTail(c.Request.Context(), request.Files, request.Limit)
|
||||
} else {
|
||||
reader, err = node.LXCTail(c.Request.Context(), *request.VMID, request.Files, request.Limit)
|
||||
}
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to get journalctl output"))
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
manager, err := websocket.NewManagerWithUpgrade(c)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
|
||||
return
|
||||
}
|
||||
defer manager.Close()
|
||||
|
||||
writer := manager.NewWriter(websocket.TextMessage)
|
||||
_, err = io.Copy(writer, reader)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to copy journalctl output"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
statequery "github.com/yusing/godoxy/internal/config/query"
|
||||
"github.com/yusing/godoxy/internal/route/routes"
|
||||
apitypes "github.com/yusing/goutils/apitypes"
|
||||
)
|
||||
@@ -33,17 +32,10 @@ func Route(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
route, ok := routes.Get(request.Which)
|
||||
route, ok := routes.GetIncludeExcluded(request.Which)
|
||||
if ok {
|
||||
c.JSON(http.StatusOK, route)
|
||||
return
|
||||
}
|
||||
|
||||
// also search for excluded routes
|
||||
route = statequery.SearchRoute(request.Which)
|
||||
if route != nil {
|
||||
c.JSON(http.StatusOK, route)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, nil)
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("route not found"))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
"github.com/yusing/godoxy/internal/dnsproviders"
|
||||
@@ -25,9 +26,9 @@ func TestEABConfigRequired(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
yaml := fmt.Appendf(nil, "eab_kid: %s\neab_hmac: %s", test.cfg.EABKid, test.cfg.EABHmac)
|
||||
yamlCfg := fmt.Appendf(nil, "eab_kid: %s\neab_hmac: %s", test.cfg.EABKid, test.cfg.EABHmac)
|
||||
cfg := autocert.Config{}
|
||||
err := serialization.UnmarshalValidateYAML(yaml, &cfg)
|
||||
err := serialization.UnmarshalValidate(yamlCfg, &cfg, yaml.Unmarshal)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, test.wantErr)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
@@ -41,7 +42,7 @@ func TestMultipleCertificatesLifecycle(t *testing.T) {
|
||||
cfg.HTTPClient = acmeServer.httpClient()
|
||||
|
||||
/* unmarshal yaml config with multiple certs */
|
||||
err := error(serialization.UnmarshalValidateYAML(yamlConfig, &cfg))
|
||||
err := error(serialization.UnmarshalValidate(yamlConfig, &cfg, yaml.Unmarshal))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"main.example.com"}, cfg.Domains)
|
||||
require.Len(t, cfg.Extra, 2)
|
||||
|
||||
@@ -3,6 +3,7 @@ package autocert_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
"github.com/yusing/godoxy/internal/dnsproviders"
|
||||
@@ -42,7 +43,7 @@ extra:
|
||||
`
|
||||
|
||||
var cfg autocert.Config
|
||||
err := error(serialization.UnmarshalValidateYAML([]byte(cfgYAML), &cfg))
|
||||
err := error(serialization.UnmarshalValidate([]byte(cfgYAML), &cfg, yaml.Unmarshal))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test: extra[0] inherits all fields from main except CertPath and KeyPath.
|
||||
|
||||
@@ -13,6 +13,8 @@ var (
|
||||
IsDebug = env.GetEnvBool("DEBUG", IsTest)
|
||||
IsTrace = env.GetEnvBool("TRACE", false) && IsDebug
|
||||
|
||||
InitTimeout = env.GetEnvDuation("INIT_TIMEOUT", 1*time.Minute)
|
||||
|
||||
ShortLinkPrefix = env.GetEnvString("SHORTLINK_PREFIX", "go")
|
||||
|
||||
ProxyHTTPAddr,
|
||||
@@ -30,6 +32,11 @@ var (
|
||||
APIHTTPPort,
|
||||
APIHTTPURL = env.GetAddrEnv("API_ADDR", "127.0.0.1:8888", "http")
|
||||
|
||||
LocalAPIHTTPAddr,
|
||||
LocalAPIHTTPHost,
|
||||
LocalAPIHTTPPort,
|
||||
LocalAPIHTTPURL = env.GetAddrEnv("LOCAL_API_ADDR", "", "http")
|
||||
|
||||
APIJWTSecure = env.GetEnvBool("API_JWT_SECURE", true)
|
||||
APIJWTSecret = decodeJWTKey(env.GetEnvString("API_JWT_SECRET", ""))
|
||||
APIJWTTokenTTL = env.GetEnvDuation("API_JWT_TOKEN_TTL", 24*time.Hour)
|
||||
|
||||
@@ -54,12 +54,6 @@ Returns all route providers as a map keyed by their short name. Thread-safe acce
|
||||
func RouteProviderList() []RouteProviderListResponse
|
||||
```
|
||||
|
||||
Returns a list of route providers with their short and full names. Useful for API responses.
|
||||
|
||||
```go
|
||||
func SearchRoute(alias string) types.Route
|
||||
```
|
||||
|
||||
Searches for a route by alias across all providers. Returns `nil` if not found.
|
||||
|
||||
```go
|
||||
@@ -179,15 +173,6 @@ for shortName, provider := range providers {
|
||||
}
|
||||
```
|
||||
|
||||
### Searching for a route
|
||||
|
||||
```go
|
||||
route := statequery.SearchRoute("my-service")
|
||||
if route != nil {
|
||||
fmt.Printf("Found route: %s\n", route.Alias())
|
||||
}
|
||||
```
|
||||
|
||||
### Getting system statistics
|
||||
|
||||
```go
|
||||
@@ -213,14 +198,4 @@ func handleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
||||
func handleFindRoute(w http.ResponseWriter, r *http.Request) {
|
||||
alias := r.URL.Query().Get("alias")
|
||||
route := statequery.SearchRoute(alias)
|
||||
if route == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(route)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -30,13 +30,3 @@ func RouteProviderList() []RouteProviderListResponse {
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func SearchRoute(alias string) types.Route {
|
||||
state := config.ActiveState.Load()
|
||||
for _, p := range state.IterProviders() {
|
||||
if r, ok := p.GetRoute(alias); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func (state *state) InitFromFile(filename string) error {
|
||||
}
|
||||
|
||||
func (state *state) Init(data []byte) error {
|
||||
err := serialization.UnmarshalValidateYAML(data, &state.Config)
|
||||
err := serialization.UnmarshalValidate(data, &state.Config, yaml.Unmarshal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -111,7 +111,6 @@ func (state *state) Init(data []byte) error {
|
||||
g := gperr.NewGroup("config load error")
|
||||
g.Go(state.initMaxMind)
|
||||
g.Go(state.initProxmox)
|
||||
g.Go(state.loadRouteProviders)
|
||||
g.Go(state.initAutoCert)
|
||||
|
||||
errs := g.Wait()
|
||||
@@ -119,6 +118,7 @@ func (state *state) Init(data []byte) error {
|
||||
errs.Add(state.initNotification())
|
||||
errs.Add(state.initACL())
|
||||
errs.Add(state.initEntrypoint())
|
||||
errs.Add(state.loadRouteProviders())
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
@@ -317,67 +317,50 @@ func (state *state) initProxmox() error {
|
||||
return errs.Wait().Error()
|
||||
}
|
||||
|
||||
func (state *state) storeProvider(p types.RouteProvider) {
|
||||
state.providers.Store(p.String(), p)
|
||||
}
|
||||
|
||||
func (state *state) loadRouteProviders() error {
|
||||
providers := &state.Providers
|
||||
providers := state.Providers
|
||||
errs := gperr.NewGroup("route provider errors")
|
||||
results := gperr.NewGroup("loaded route providers")
|
||||
|
||||
agentpool.RemoveAll()
|
||||
|
||||
numProviders := len(providers.Agents) + len(providers.Files) + len(providers.Docker)
|
||||
providersCh := make(chan types.RouteProvider, numProviders)
|
||||
|
||||
// start providers concurrently
|
||||
var providersConsumer sync.WaitGroup
|
||||
providersConsumer.Go(func() {
|
||||
for p := range providersCh {
|
||||
if actual, loaded := state.providers.LoadOrStore(p.String(), p); loaded {
|
||||
errs.Add(gperr.Errorf("provider %s already exists, first: %s, second: %s", p.String(), actual.GetType(), p.GetType()))
|
||||
continue
|
||||
}
|
||||
state.storeProvider(p)
|
||||
registerProvider := func(p types.RouteProvider) {
|
||||
if actual, loaded := state.providers.LoadOrStore(p.String(), p); loaded {
|
||||
errs.Addf("provider %s already exists, first: %s, second: %s", p.String(), actual.GetType(), p.GetType())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var providersProducer sync.WaitGroup
|
||||
agentErrs := gperr.NewGroup("agent init errors")
|
||||
for _, a := range providers.Agents {
|
||||
providersProducer.Go(func() {
|
||||
agentErrs.Go(func() error {
|
||||
if err := a.Init(state.task.Context()); err != nil {
|
||||
errs.Add(gperr.PrependSubject(a.String(), err))
|
||||
return
|
||||
return gperr.PrependSubject(a.String(), err)
|
||||
}
|
||||
agentpool.Add(a)
|
||||
p := route.NewAgentProvider(a)
|
||||
providersCh <- p
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := agentErrs.Wait().Error(); err != nil {
|
||||
errs.Add(err)
|
||||
}
|
||||
|
||||
for _, a := range providers.Agents {
|
||||
registerProvider(route.NewAgentProvider(a))
|
||||
}
|
||||
|
||||
for _, filename := range providers.Files {
|
||||
providersProducer.Go(func() {
|
||||
p, err := route.NewFileProvider(filename)
|
||||
if err != nil {
|
||||
errs.Add(gperr.PrependSubject(filename, err))
|
||||
} else {
|
||||
providersCh <- p
|
||||
}
|
||||
})
|
||||
p, err := route.NewFileProvider(filename)
|
||||
if err != nil {
|
||||
errs.Add(gperr.PrependSubject(filename, err))
|
||||
return err
|
||||
}
|
||||
registerProvider(p)
|
||||
}
|
||||
|
||||
for name, dockerCfg := range providers.Docker {
|
||||
providersProducer.Go(func() {
|
||||
providersCh <- route.NewDockerProvider(name, dockerCfg)
|
||||
})
|
||||
registerProvider(route.NewDockerProvider(name, dockerCfg))
|
||||
}
|
||||
|
||||
providersProducer.Wait()
|
||||
|
||||
close(providersCh)
|
||||
providersConsumer.Wait()
|
||||
|
||||
lenLongestName := 0
|
||||
for k := range state.providers.Range {
|
||||
if len(k) > lenLongestName {
|
||||
@@ -386,18 +369,26 @@ func (state *state) loadRouteProviders() error {
|
||||
}
|
||||
|
||||
// load routes concurrently
|
||||
var providersLoader sync.WaitGroup
|
||||
loadErrs := gperr.NewGroup("route load errors")
|
||||
|
||||
results := gperr.NewBuilder("loaded route providers")
|
||||
resultsMu := sync.Mutex{}
|
||||
for _, p := range state.providers.Range {
|
||||
providersLoader.Go(func() {
|
||||
loadErrs.Go(func() error {
|
||||
if err := p.LoadRoutes(); err != nil {
|
||||
errs.Add(err.Subject(p.String()))
|
||||
return err.Subject(p.String())
|
||||
}
|
||||
resultsMu.Lock()
|
||||
results.Addf("%-"+strconv.Itoa(lenLongestName)+"s %d routes", p.String(), p.NumRoutes())
|
||||
resultsMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
providersLoader.Wait()
|
||||
if err := loadErrs.Wait().Error(); err != nil {
|
||||
errs.Add(err)
|
||||
}
|
||||
|
||||
state.tmpLog.Info().Msg(results.Wait().String())
|
||||
state.tmpLog.Info().Msg(results.String())
|
||||
state.printRoutesByProvider(lenLongestName)
|
||||
state.printState()
|
||||
return errs.Wait().Error()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/yusing/godoxy/agent/pkg/agent"
|
||||
"github.com/yusing/godoxy/internal/acl"
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
@@ -36,14 +37,14 @@ type (
|
||||
Docker map[string]types.DockerProviderConfig `json:"docker" yaml:"docker,omitempty" validate:"non_empty_docker_keys"`
|
||||
Agents []*agent.AgentConfig `json:"agents" yaml:"agents,omitempty"`
|
||||
Notification []*notif.NotificationConfig `json:"notification" yaml:"notification,omitempty"`
|
||||
Proxmox []proxmox.Config `json:"proxmox" yaml:"proxmox,omitempty"`
|
||||
Proxmox []*proxmox.Config `json:"proxmox" yaml:"proxmox,omitempty"`
|
||||
MaxMind *maxmind.Config `json:"maxmind" yaml:"maxmind,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
func Validate(data []byte) gperr.Error {
|
||||
var model Config
|
||||
return serialization.UnmarshalValidateYAML(data, &model)
|
||||
return serialization.UnmarshalValidate(data, &model, yaml.Unmarshal)
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
|
||||
@@ -6,11 +6,11 @@ replace github.com/yusing/godoxy => ../..
|
||||
|
||||
require (
|
||||
github.com/go-acme/lego/v4 v4.31.0
|
||||
github.com/yusing/godoxy v0.24.1
|
||||
github.com/yusing/godoxy v0.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.18.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
@@ -24,8 +24,8 @@ require (
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
@@ -61,18 +61,18 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/maxatome/go-testdeep v1.14.0 // indirect
|
||||
github.com/miekg/dns v1.1.70 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/nrdcg/goacmedns v0.2.0 // indirect
|
||||
github.com/nrdcg/goinwx v0.12.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1 // indirect
|
||||
github.com/nrdcg/porkbun v0.4.0 // indirect
|
||||
github.com/ovh/go-ovh v1.9.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 // indirect
|
||||
github.com/sony/gobreaker v1.0.0 // indirect
|
||||
@@ -98,8 +98,8 @@ require (
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/api v0.260.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/api v0.262.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.1 // indirect
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
|
||||
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
|
||||
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
@@ -39,10 +39,10 @@ github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVk
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -142,18 +142,18 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
|
||||
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
|
||||
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
|
||||
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0=
|
||||
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
|
||||
github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
|
||||
github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.0 h1:4MRzV6spwPHKct+4/ETqkEtr39Hq+0KvxhsgqbgQ2Bo=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.0/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.0 h1:RxraLVYX3eMUfQ1pDtJVvykEFGheky2YsrUt2HHRDcw=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.0/go.mod h1:JLMEKMX8IYPZ1TUSVHAVAbtnNSfP/I8OZQkAnfEMA0I=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1 h1:+fx2mbWeR8XX/vidwpRMepJMtRIYQP44Iezm2oeObVM=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1 h1:GDhBiaIAm/QXLzHJ0ASDdY/6R/9w60+gk8lY5rgfxEQ=
|
||||
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1/go.mod h1:EHScJdbM0gg5Is7e3C0ceRYAFMMsfP4Vf8sBRoxoTgk=
|
||||
github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
|
||||
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
|
||||
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
|
||||
@@ -166,8 +166,8 @@ 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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 h1:w/bWkEJdYuRNYhHn5eXnIT8LzDM1O629X1I9MJSkD7Q=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
@@ -249,14 +249,14 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4=
|
||||
google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o=
|
||||
google.golang.org/api v0.262.0 h1:4B+3u8He2GwyN8St3Jhnd3XRHlIvc//sBmgHSp78oNY=
|
||||
google.golang.org/api v0.262.0/go.mod h1:jNwmH8BgUBJ/VrUG6/lIl9YiildyLd09r9ZLHiQ6cGI=
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -152,7 +152,7 @@ func NewClient(cfg types.DockerProviderConfig, unique ...bool) (*SharedClient, e
|
||||
if agent.IsDockerHostAgent(host) {
|
||||
a, ok := agentpool.Get(host)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("agent %q not found", host))
|
||||
return nil, fmt.Errorf("agent %q not found", host)
|
||||
}
|
||||
opt = []client.Opt{
|
||||
client.WithHost(agent.DockerHost),
|
||||
|
||||
1
internal/go-proxmox
Submodule
1
internal/go-proxmox
Submodule
Submodule internal/go-proxmox added at 7a07c21f07
@@ -55,20 +55,20 @@ func init() {
|
||||
|
||||
func InitCache() {
|
||||
m := make(IconMap)
|
||||
err := serialization.LoadJSONIfExist(common.IconListCachePath, &m)
|
||||
err := serialization.LoadFileIfExist(common.IconListCachePath, &m, sonic.Unmarshal)
|
||||
if err != nil {
|
||||
// backward compatible
|
||||
oldFormat := struct {
|
||||
Icons IconMap
|
||||
LastUpdate time.Time
|
||||
}{}
|
||||
err = serialization.LoadJSONIfExist(common.IconListCachePath, &oldFormat)
|
||||
err = serialization.LoadFileIfExist(common.IconListCachePath, &oldFormat, sonic.Unmarshal)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to load icons")
|
||||
} else {
|
||||
m = oldFormat.Icons
|
||||
// store it to disk immediately
|
||||
_ = serialization.SaveJSON(common.IconListCachePath, &m, 0o644)
|
||||
_ = serialization.SaveFile(common.IconListCachePath, &m, 0o644, sonic.Marshal)
|
||||
}
|
||||
} else if len(m) > 0 {
|
||||
log.Info().
|
||||
@@ -84,7 +84,7 @@ func InitCache() {
|
||||
|
||||
task.OnProgramExit("save_icons_cache", func() {
|
||||
icons := iconsCache.Load()
|
||||
_ = serialization.SaveJSON(common.IconListCachePath, &icons, 0o644)
|
||||
_ = serialization.SaveFile(common.IconListCachePath, &icons, 0o644, sonic.Marshal)
|
||||
})
|
||||
|
||||
go backgroundUpdateIcons()
|
||||
@@ -105,7 +105,7 @@ func backgroundUpdateIcons() {
|
||||
// swap old cache with new cache
|
||||
iconsCache.Store(newCache)
|
||||
// save it to disk
|
||||
err := serialization.SaveJSON(common.IconListCachePath, &newCache, 0o644)
|
||||
err := serialization.SaveFile(common.IconListCachePath, &newCache, 0o644, sonic.Marshal)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("failed to save icons")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ const proxmoxStateCheckInterval = 1 * time.Second
|
||||
var ErrNodeNotFound = gperr.New("node not found in pool")
|
||||
|
||||
func NewProxmoxProvider(ctx context.Context, nodeName string, vmid int) (idlewatcher.Provider, error) {
|
||||
if nodeName == "" || vmid == 0 {
|
||||
return nil, gperr.New("node name and vmid are required")
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(nodeName)
|
||||
if !ok {
|
||||
return nil, ErrNodeNotFound.Subject(nodeName).
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package idlewatcher
|
||||
@@ -173,7 +173,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
depRoute, ok = routes.Get(dep)
|
||||
depRoute, ok = routes.GetIncludeExcluded(dep)
|
||||
if !ok {
|
||||
depErrors.Addf("dependency %q not found", dep)
|
||||
continue
|
||||
|
||||
@@ -83,7 +83,8 @@ func loadNS[T store](ns namespace) T {
|
||||
func save() error {
|
||||
errs := gperr.NewBuilder("failed to save data stores")
|
||||
for ns, store := range stores {
|
||||
if err := serialization.SaveJSON(filepath.Join(storesPath, string(ns)+".json"), &store, 0o644); err != nil {
|
||||
path := filepath.Join(storesPath, string(ns)+".json")
|
||||
if err := serialization.SaveFile(path, &store, 0o644, sonic.Marshal); err != nil {
|
||||
errs.Add(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
type (
|
||||
ConfigBase struct {
|
||||
Path string `json:"path"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Stdout bool `json:"stdout"`
|
||||
Retention *Retention `json:"retention" aliases:"keep"`
|
||||
RotateInterval time.Duration `json:"rotate_interval,omitempty" swaggertype:"primitive,integer"`
|
||||
@@ -37,16 +37,16 @@ type (
|
||||
|
||||
Format string
|
||||
Filters struct {
|
||||
StatusCodes LogFilter[*StatusCodeRange] `json:"status_codes"`
|
||||
Method LogFilter[HTTPMethod] `json:"method"`
|
||||
Host LogFilter[Host] `json:"host"`
|
||||
Headers LogFilter[*HTTPHeader] `json:"headers"` // header exists or header == value
|
||||
CIDR LogFilter[*CIDR] `json:"cidr"`
|
||||
StatusCodes LogFilter[*StatusCodeRange] `json:"status_codes,omitzero"`
|
||||
Method LogFilter[HTTPMethod] `json:"method,omitzero"`
|
||||
Host LogFilter[Host] `json:"host,omitzero"`
|
||||
Headers LogFilter[*HTTPHeader] `json:"headers,omitzero"` // header exists or header == value
|
||||
CIDR LogFilter[*CIDR] `json:"cidr,omitzero"`
|
||||
}
|
||||
Fields struct {
|
||||
Headers FieldConfig `json:"headers" aliases:"header"`
|
||||
Query FieldConfig `json:"query" aliases:"queries"`
|
||||
Cookies FieldConfig `json:"cookies" aliases:"cookie"`
|
||||
Headers FieldConfig `json:"headers,omitzero" aliases:"header"`
|
||||
Query FieldConfig `json:"query,omitzero" aliases:"queries"`
|
||||
Cookies FieldConfig `json:"cookies,omitzero" aliases:"cookie"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
)
|
||||
|
||||
type Retention struct {
|
||||
Days uint64 `json:"days"`
|
||||
Last uint64 `json:"last"`
|
||||
KeepSize uint64 `json:"keep_size"`
|
||||
Days uint64 `json:"days,omitempty"`
|
||||
Last uint64 `json:"last,omitempty"`
|
||||
KeepSize uint64 `json:"keep_size,omitempty"`
|
||||
} // @name LogRetention
|
||||
|
||||
var (
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
|
||||
"github.com/rs/zerolog/diode"
|
||||
zerologlog "github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -68,7 +69,13 @@ func fmtMessage(msg string) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func multiLevelWriter(out ...io.Writer) io.Writer {
|
||||
func diodeMultiWriter(out ...io.Writer) io.Writer {
|
||||
return diode.NewWriter(multiWriter(out...), 1024, 0, func(missed int) {
|
||||
zerologlog.Warn().Int("missed", missed).Msg("missed log messages")
|
||||
})
|
||||
}
|
||||
|
||||
func multiWriter(out ...io.Writer) io.Writer {
|
||||
if len(out) == 0 {
|
||||
return os.Stdout
|
||||
}
|
||||
@@ -80,7 +87,7 @@ func multiLevelWriter(out ...io.Writer) io.Writer {
|
||||
|
||||
func NewLogger(out ...io.Writer) zerolog.Logger {
|
||||
writer := zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
|
||||
w.Out = multiLevelWriter(out...)
|
||||
w.Out = diodeMultiWriter(out...)
|
||||
w.TimeFormat = timeFmt
|
||||
w.FormatMessage = func(msgI any) string { // pad spaces for each line
|
||||
if msgI == nil {
|
||||
@@ -92,9 +99,9 @@ func NewLogger(out ...io.Writer) zerolog.Logger {
|
||||
return zerolog.New(writer).Level(level).With().Timestamp().Logger()
|
||||
}
|
||||
|
||||
func NewLoggerWithFixedLevel(level zerolog.Level, out ...io.Writer) zerolog.Logger {
|
||||
func NewLoggerWithFixedLevel(lvl zerolog.Level, out ...io.Writer) zerolog.Logger {
|
||||
writer := zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
|
||||
w.Out = multiLevelWriter(out...)
|
||||
w.Out = diodeMultiWriter(out...)
|
||||
w.TimeFormat = timeFmt
|
||||
w.FormatMessage = func(msgI any) string { // pad spaces for each line
|
||||
if msgI == nil {
|
||||
@@ -103,5 +110,5 @@ func NewLoggerWithFixedLevel(level zerolog.Level, out ...io.Writer) zerolog.Logg
|
||||
return fmtMessage(msgI.(string))
|
||||
}
|
||||
})
|
||||
return zerolog.New(writer).Level(level).With().Str("level", level.String()).Timestamp().Logger()
|
||||
return zerolog.New(writer).Level(level).With().Str("level", lvl.String()).Timestamp().Logger()
|
||||
}
|
||||
|
||||
@@ -2,42 +2,31 @@ package memlogger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
apitypes "github.com/yusing/goutils/apitypes"
|
||||
"github.com/yusing/goutils/http/websocket"
|
||||
)
|
||||
|
||||
type logEntryRange struct {
|
||||
Start, End int
|
||||
}
|
||||
|
||||
type memLogger struct {
|
||||
*bytes.Buffer
|
||||
sync.RWMutex
|
||||
buf *bytes.Buffer
|
||||
bufLock sync.RWMutex
|
||||
|
||||
notifyLock sync.RWMutex
|
||||
connChans *xsync.Map[chan *logEntryRange, struct{}]
|
||||
listeners *xsync.Map[chan []byte, struct{}]
|
||||
channelLock sync.RWMutex
|
||||
listeners *xsync.Map[chan []byte, struct{}]
|
||||
}
|
||||
|
||||
type MemLogger io.Writer
|
||||
|
||||
const (
|
||||
maxMemLogSize = 16 * 1024
|
||||
truncateSize = maxMemLogSize / 2
|
||||
initialWriteChunkSize = 4 * 1024
|
||||
writeTimeout = 10 * time.Second
|
||||
maxMemLogSize = 16 * 1024
|
||||
truncateSize = maxMemLogSize / 2
|
||||
listenerChanBufSize = 64
|
||||
)
|
||||
|
||||
var memLoggerInstance = &memLogger{
|
||||
Buffer: bytes.NewBuffer(make([]byte, maxMemLogSize)),
|
||||
connChans: xsync.NewMap[chan *logEntryRange, struct{}](),
|
||||
buf: bytes.NewBuffer(make([]byte, 0, maxMemLogSize)),
|
||||
listeners: xsync.NewMap[chan []byte, struct{}](),
|
||||
}
|
||||
|
||||
@@ -45,10 +34,6 @@ func GetMemLogger() MemLogger {
|
||||
return memLoggerInstance
|
||||
}
|
||||
|
||||
func HandlerFunc() gin.HandlerFunc {
|
||||
return memLoggerInstance.ServeHTTP
|
||||
}
|
||||
|
||||
func Events() (<-chan []byte, func()) {
|
||||
return memLoggerInstance.events()
|
||||
}
|
||||
@@ -56,136 +41,90 @@ func Events() (<-chan []byte, func()) {
|
||||
// Write implements io.Writer.
|
||||
func (m *memLogger) Write(p []byte) (n int, err error) {
|
||||
n = len(p)
|
||||
if n == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
m.truncateIfNeeded(n)
|
||||
|
||||
pos, err := m.writeBuf(p)
|
||||
err = m.writeBuf(p)
|
||||
if err != nil {
|
||||
// not logging the error here, it will cause Run to be called again = infinite loop
|
||||
return n, err
|
||||
}
|
||||
|
||||
m.notifyWS(pos, n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (m *memLogger) ServeHTTP(c *gin.Context) {
|
||||
manager, err := websocket.NewManagerWithUpgrade(c)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to create websocket manager"))
|
||||
return
|
||||
if m.listeners.Size() == 0 {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
logCh := make(chan *logEntryRange)
|
||||
m.connChans.Store(logCh, struct{}{})
|
||||
|
||||
defer func() {
|
||||
manager.Close()
|
||||
m.notifyLock.Lock()
|
||||
m.connChans.Delete(logCh)
|
||||
close(logCh)
|
||||
m.notifyLock.Unlock()
|
||||
}()
|
||||
|
||||
if err := m.wsInitial(manager); err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to send initial log"))
|
||||
return
|
||||
}
|
||||
|
||||
m.wsStreamLog(c.Request.Context(), manager, logCh)
|
||||
msg := slices.Clone(p)
|
||||
m.notifyWS(msg)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (m *memLogger) truncateIfNeeded(n int) {
|
||||
m.RLock()
|
||||
needTruncate := m.Len()+n > maxMemLogSize
|
||||
m.RUnlock()
|
||||
m.bufLock.RLock()
|
||||
needTruncate := m.buf.Len()+n > maxMemLogSize
|
||||
m.bufLock.RUnlock()
|
||||
|
||||
if needTruncate {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
needTruncate = m.Len()+n > maxMemLogSize
|
||||
if !needTruncate {
|
||||
return
|
||||
}
|
||||
|
||||
m.Truncate(truncateSize)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memLogger) notifyWS(pos, n int) {
|
||||
if m.connChans.Size() == 0 && m.listeners.Size() == 0 {
|
||||
if !needTruncate {
|
||||
return
|
||||
}
|
||||
|
||||
timeout := time.NewTimer(3 * time.Second)
|
||||
defer timeout.Stop()
|
||||
m.bufLock.Lock()
|
||||
defer m.bufLock.Unlock()
|
||||
|
||||
m.notifyLock.RLock()
|
||||
defer m.notifyLock.RUnlock()
|
||||
|
||||
m.connChans.Range(func(ch chan *logEntryRange, _ struct{}) bool {
|
||||
select {
|
||||
case ch <- &logEntryRange{pos, pos + n}:
|
||||
return true
|
||||
case <-timeout.C:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if m.listeners.Size() > 0 {
|
||||
msg := m.Bytes()[pos : pos+n]
|
||||
m.listeners.Range(func(ch chan []byte, _ struct{}) bool {
|
||||
select {
|
||||
case <-timeout.C:
|
||||
return false
|
||||
case ch <- msg:
|
||||
return true
|
||||
}
|
||||
})
|
||||
discard := m.buf.Len() - truncateSize
|
||||
if discard > 0 {
|
||||
_ = m.buf.Next(discard)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memLogger) writeBuf(b []byte) (pos int, err error) {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
pos = m.Len()
|
||||
_, err = m.Buffer.Write(b)
|
||||
return pos, err
|
||||
func (m *memLogger) notifyWS(msg []byte) {
|
||||
if len(msg) == 0 || m.listeners.Size() == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
m.channelLock.RLock()
|
||||
defer m.channelLock.RUnlock()
|
||||
|
||||
for ch := range m.listeners.Range {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memLogger) writeBuf(b []byte) (err error) {
|
||||
m.bufLock.Lock()
|
||||
defer m.bufLock.Unlock()
|
||||
|
||||
_, err = m.buf.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if m.buf.Len() > maxMemLogSize {
|
||||
discard := m.buf.Len() - maxMemLogSize
|
||||
if discard > 0 {
|
||||
_ = m.buf.Next(discard)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memLogger) events() (logs <-chan []byte, cancel func()) {
|
||||
ch := make(chan []byte)
|
||||
m.notifyLock.Lock()
|
||||
defer m.notifyLock.Unlock()
|
||||
ch := make(chan []byte, listenerChanBufSize)
|
||||
m.channelLock.Lock()
|
||||
defer m.channelLock.Unlock()
|
||||
m.listeners.Store(ch, struct{}{})
|
||||
|
||||
return ch, func() {
|
||||
m.notifyLock.Lock()
|
||||
defer m.notifyLock.Unlock()
|
||||
m.channelLock.Lock()
|
||||
defer m.channelLock.Unlock()
|
||||
m.listeners.Delete(ch)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memLogger) wsInitial(manager *websocket.Manager) error {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
return manager.WriteData(websocket.TextMessage, m.Bytes(), writeTimeout)
|
||||
}
|
||||
|
||||
func (m *memLogger) wsStreamLog(ctx context.Context, manager *websocket.Manager, ch <-chan *logEntryRange) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case logRange := <-ch:
|
||||
m.RLock()
|
||||
msg := m.Bytes()[logRange.Start:logRange.End]
|
||||
err := manager.WriteData(websocket.TextMessage, msg, writeTimeout)
|
||||
m.RUnlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,13 +55,10 @@ Individual route status at a point in time.
|
||||
```go
|
||||
type RouteAggregate struct {
|
||||
Alias string `json:"alias"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Uptime float32 `json:"uptime"`
|
||||
Downtime float32 `json:"downtime"`
|
||||
Idle float32 `json:"idle"`
|
||||
AvgLatency float32 `json:"avg_latency"`
|
||||
IsDocker bool `json:"is_docker"`
|
||||
IsExcluded bool `json:"is_excluded"`
|
||||
CurrentStatus types.HealthStatus `json:"current_status" swaggertype:"string" enums:"healthy,unhealthy,unknown,napping,starting"`
|
||||
Statuses []Status `json:"statuses"`
|
||||
}
|
||||
@@ -312,7 +309,7 @@ const ws = new WebSocket(
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
data.data.forEach((route) => {
|
||||
console.log(`${route.display_name}: ${route.uptime * 100}% uptime`);
|
||||
console.log(`${route.alias}: ${route.uptime * 100}% uptime`);
|
||||
});
|
||||
};
|
||||
```
|
||||
@@ -336,7 +333,7 @@ _, agg := uptime.aggregateStatuses(entries, url.Values{
|
||||
|
||||
for _, route := range agg {
|
||||
fmt.Printf("%s: %.1f%% uptime, %.1fms avg latency\n",
|
||||
route.DisplayName, route.Uptime*100, route.AvgLatency)
|
||||
route.Alias, route.Uptime*100, route.AvgLatency)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -365,13 +362,10 @@ for _, route := range agg {
|
||||
"data": [
|
||||
{
|
||||
"alias": "api-server",
|
||||
"display_name": "API Server",
|
||||
"uptime": 0.98,
|
||||
"downtime": 0.02,
|
||||
"idle": 0.0,
|
||||
"avg_latency": 45.5,
|
||||
"is_docker": true,
|
||||
"is_excluded": false,
|
||||
"current_status": "healthy",
|
||||
"statuses": [
|
||||
{ "status": "healthy", "latency": 45, "timestamp": 1704892800 }
|
||||
|
||||
@@ -27,13 +27,10 @@ type (
|
||||
RouteStatuses map[string][]Status // @name RouteStatuses
|
||||
RouteAggregate struct {
|
||||
Alias string `json:"alias"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Uptime float32 `json:"uptime"`
|
||||
Downtime float32 `json:"downtime"`
|
||||
Idle float32 `json:"idle"`
|
||||
AvgLatency float32 `json:"avg_latency"`
|
||||
IsDocker bool `json:"is_docker"`
|
||||
IsExcluded bool `json:"is_excluded"`
|
||||
CurrentStatus types.HealthStatus `json:"current_status" swaggertype:"string" enums:"healthy,unhealthy,unknown,napping,starting"`
|
||||
Statuses []Status `json:"statuses"`
|
||||
} // @name RouteUptimeAggregate
|
||||
@@ -129,18 +126,9 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated {
|
||||
statuses := rs[alias]
|
||||
up, down, idle, latency := rs.calculateInfo(statuses)
|
||||
|
||||
displayName := alias
|
||||
r, ok := routes.Get(alias)
|
||||
if !ok {
|
||||
// also search for excluded routes
|
||||
r, ok = routes.Excluded.Get(alias)
|
||||
}
|
||||
if r != nil {
|
||||
displayName = r.DisplayName()
|
||||
}
|
||||
|
||||
status := types.StatusUnknown
|
||||
if r != nil {
|
||||
r, ok := routes.GetIncludeExcluded(alias)
|
||||
if ok {
|
||||
mon := r.HealthMonitor()
|
||||
if mon != nil {
|
||||
status = mon.Status()
|
||||
@@ -149,15 +137,12 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated {
|
||||
|
||||
result[i] = RouteAggregate{
|
||||
Alias: alias,
|
||||
DisplayName: displayName,
|
||||
Uptime: up,
|
||||
Downtime: down,
|
||||
Idle: idle,
|
||||
AvgLatency: latency,
|
||||
CurrentStatus: status,
|
||||
Statuses: statuses,
|
||||
IsDocker: r != nil && r.IsDocker(),
|
||||
IsExcluded: r == nil || r.ShouldExclude(),
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -33,7 +33,7 @@ type (
|
||||
|
||||
task *task.Task
|
||||
|
||||
pool pool.Pool[types.LoadBalancerServer]
|
||||
pool *pool.Pool[types.LoadBalancerServer]
|
||||
poolMu sync.Mutex
|
||||
|
||||
sumWeight int
|
||||
|
||||
@@ -10,10 +10,15 @@ The proxmox package implements Proxmox API client management, node discovery, an
|
||||
|
||||
- Proxmox API client management
|
||||
- Node discovery and pool management
|
||||
- LXC container operations (start, stop, status)
|
||||
- IP address retrieval for containers
|
||||
- LXC container operations (start, stop, status, stats, command execution)
|
||||
- IP address retrieval for containers (online and offline)
|
||||
- Container stats streaming (like `docker stats`)
|
||||
- Container command execution via VNC websocket
|
||||
- Journalctl streaming for LXC containers
|
||||
- Reverse resource lookup by IP, hostname, or alias
|
||||
- Reverse node lookup by hostname, IP, or alias
|
||||
- TLS configuration options
|
||||
- Token-based authentication
|
||||
- Token and username/password authentication
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -29,12 +34,14 @@ graph TD
|
||||
G --> I[Start Container]
|
||||
G --> J[Stop Container]
|
||||
G --> K[Check Status]
|
||||
G --> L[Execute Command]
|
||||
G --> M[Stream Stats]
|
||||
|
||||
subgraph Node Pool
|
||||
F --> L[Nodes Map]
|
||||
L --> M[Node 1]
|
||||
L --> N[Node 2]
|
||||
L --> O[Node 3]
|
||||
F --> N[Nodes Map]
|
||||
N --> O[Node 1]
|
||||
N --> P[Node 2]
|
||||
N --> Q[Node 3]
|
||||
end
|
||||
```
|
||||
|
||||
@@ -45,8 +52,11 @@ graph TD
|
||||
```go
|
||||
type Config struct {
|
||||
URL string `json:"url" validate:"required,url"`
|
||||
TokenID string `json:"token_id" validate:"required"`
|
||||
Secret strutils.Redacted `json:"secret" validate:"required"`
|
||||
Username string `json:"username" validate:"required_without_all=TokenID Secret"`
|
||||
Password strutils.Redacted `json:"password" validate:"required_without_all=TokenID Secret"`
|
||||
Realm string `json:"realm"`
|
||||
TokenID string `json:"token_id" validate:"required_without_all=Username Password"`
|
||||
Secret strutils.Redacted `json:"secret" validate:"required_without_all=Username Password"`
|
||||
NoTLSVerify bool `json:"no_tls_verify"`
|
||||
|
||||
client *Client
|
||||
@@ -58,9 +68,21 @@ type Config struct {
|
||||
```go
|
||||
type Client struct {
|
||||
*proxmox.Client
|
||||
proxmox.Cluster
|
||||
*proxmox.Cluster
|
||||
Version *proxmox.Version
|
||||
BaseURL *url.URL
|
||||
// id -> resource; id: lxc/<vmid> or qemu/<vmid>
|
||||
resources map[string]*VMResource
|
||||
resourcesMu sync.RWMutex
|
||||
}
|
||||
|
||||
type VMResource struct {
|
||||
*proxmox.ClusterResource
|
||||
IPs []net.IP
|
||||
}
|
||||
|
||||
// NewClient creates a new Proxmox client.
|
||||
func NewClient(baseUrl string, opts ...proxmox.Option) *Client
|
||||
```
|
||||
|
||||
### Node
|
||||
@@ -69,12 +91,24 @@ type Client struct {
|
||||
type Node struct {
|
||||
name string
|
||||
id string
|
||||
client *proxmox.Client
|
||||
client *Client
|
||||
}
|
||||
|
||||
var Nodes = pool.New[*Node]("proxmox_nodes")
|
||||
```
|
||||
|
||||
### NodeConfig
|
||||
|
||||
```go
|
||||
type NodeConfig struct {
|
||||
Node string `json:"node" validate:"required"`
|
||||
VMID *int `json:"vmid"` // nil: auto discover; 0: node-level route; >0: lxc/qemu resource route
|
||||
VMName string `json:"vmname,omitempty"`
|
||||
Services []string `json:"services,omitempty" aliases:"service"`
|
||||
Files []string `json:"files,omitempty" aliases:"file"`
|
||||
}
|
||||
```
|
||||
|
||||
## Public API
|
||||
|
||||
### Configuration
|
||||
@@ -87,11 +121,69 @@ func (c *Config) Init(ctx context.Context) gperr.Error
|
||||
func (c *Config) Client() *Client
|
||||
```
|
||||
|
||||
### Client Operations
|
||||
|
||||
```go
|
||||
// NewClient creates a new Proxmox client.
|
||||
func NewClient(baseUrl string, opts ...proxmox.Option) *Client
|
||||
|
||||
// UpdateClusterInfo fetches cluster info and discovers nodes.
|
||||
func (c *Client) UpdateClusterInfo(ctx context.Context) error
|
||||
|
||||
// UpdateResources fetches VM resources and their IP addresses.
|
||||
func (c *Client) UpdateResources(ctx context.Context) error
|
||||
|
||||
// GetResource gets a resource by kind and id.
|
||||
func (c *Client) GetResource(kind string, id int) (*VMResource, error)
|
||||
|
||||
// ReverseLookupResource looks up a resource by IP, hostname, or alias.
|
||||
func (c *Client) ReverseLookupResource(ip net.IP, hostname string, alias string) (*VMResource, error)
|
||||
|
||||
// ReverseLookupNode looks up a node by hostname, IP, or alias.
|
||||
func (c *Client) ReverseLookupNode(hostname string, ip net.IP, alias string) string
|
||||
|
||||
// NumNodes returns the number of nodes in the cluster.
|
||||
func (c *Client) NumNodes() int
|
||||
|
||||
// Key returns the cluster ID.
|
||||
func (c *Client) Key() string
|
||||
|
||||
// Name returns the cluster name.
|
||||
func (c *Client) Name() string
|
||||
|
||||
// MarshalJSON returns the cluster info as JSON.
|
||||
func (c *Client) MarshalJSON() ([]byte, error)
|
||||
```
|
||||
|
||||
### Node Operations
|
||||
|
||||
```go
|
||||
// AvailableNodeNames returns all available node names.
|
||||
// AvailableNodeNames returns all available node names as a comma-separated string.
|
||||
func AvailableNodeNames() string
|
||||
|
||||
// NewNode creates a new node.
|
||||
func NewNode(client *Client, name, id string) *Node
|
||||
|
||||
// Node.Client returns the Proxmox client.
|
||||
func (n *Node) Client() *Client
|
||||
|
||||
// Node.Get performs a GET request on the node.
|
||||
func (n *Node) Get(ctx context.Context, path string, v any) error
|
||||
|
||||
// Node.Key returns the node name.
|
||||
func (n *Node) Key() string
|
||||
|
||||
// Node.Name returns the node name.
|
||||
func (n *Node) Name() string
|
||||
|
||||
// NodeCommand executes a command on the node and streams output.
|
||||
func (n *Node) NodeCommand(ctx context.Context, command string) (io.ReadCloser, error)
|
||||
|
||||
// NodeJournalctl streams journalctl output from the node.
|
||||
func (n *Node) NodeJournalctl(ctx context.Context, services []string, limit int) (io.ReadCloser, error)
|
||||
|
||||
// NodeTail streams tail output for the given file.
|
||||
func (n *Node) NodeTail(ctx context.Context, files []string, limit int) (io.ReadCloser, error)
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -136,57 +228,111 @@ fmt.Printf("Available nodes: %s\n", names)
|
||||
|
||||
## LXC Operations
|
||||
|
||||
### Container Status
|
||||
|
||||
```go
|
||||
type LXCStatus string
|
||||
|
||||
const (
|
||||
LXCStatusRunning LXCStatus = "running"
|
||||
LXCStatusStopped LXCStatus = "stopped"
|
||||
LXCStatusSuspended LXCStatus = "suspended"
|
||||
)
|
||||
|
||||
// LXCStatus returns the current status of a container.
|
||||
func (node *Node) LXCStatus(ctx context.Context, vmid int) (LXCStatus, error)
|
||||
|
||||
// LXCIsRunning checks if a container is running.
|
||||
func (node *Node) LXCIsRunning(ctx context.Context, vmid int) (bool, error)
|
||||
|
||||
// LXCIsStopped checks if a container is stopped.
|
||||
func (node *Node) LXCIsStopped(ctx context.Context, vmid int) (bool, error)
|
||||
|
||||
// LXCName returns the name of a container.
|
||||
func (node *Node) LXCName(ctx context.Context, vmid int) (string, error)
|
||||
```
|
||||
|
||||
### Container Actions
|
||||
|
||||
```go
|
||||
type LXCAction string
|
||||
|
||||
const (
|
||||
LXCStart LXCAction = "start"
|
||||
LXCShutdown LXCAction = "shutdown"
|
||||
LXCSuspend LXCAction = "suspend"
|
||||
LXCResume LXCAction = "resume"
|
||||
LXCReboot LXCAction = "reboot"
|
||||
)
|
||||
|
||||
// LXCAction performs an action on a container with task tracking.
|
||||
func (node *Node) LXCAction(ctx context.Context, vmid int, action LXCAction) error
|
||||
|
||||
// LXCSetShutdownTimeout sets the shutdown timeout for a container.
|
||||
func (node *Node) LXCSetShutdownTimeout(ctx context.Context, vmid int, timeout time.Duration) error
|
||||
```
|
||||
|
||||
### Get Container IPs
|
||||
|
||||
```go
|
||||
func getContainerIPs(ctx context.Context, node *proxmox.Node, vmid int) ([]net.IP, error) {
|
||||
var ips []net.IP
|
||||
// LXCGetIPs returns IP addresses of a container.
|
||||
// First tries interfaces (online), then falls back to config (offline).
|
||||
func (node *Node) LXCGetIPs(ctx context.Context, vmid int) ([]net.IP, error)
|
||||
|
||||
err := node.Get(ctx, "/lxc/"+strconv.Itoa(vmid)+"/config", &config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// LXCGetIPsFromInterfaces returns IP addresses from network interfaces.
|
||||
// Returns empty if container is stopped.
|
||||
func (node *Node) LXCGetIPsFromInterfaces(ctx context.Context, vmid int) ([]net.IP, error)
|
||||
|
||||
// Parse IP addresses from config
|
||||
for _, ip := range config {
|
||||
if ipNet := net.ParseCIDR(ip); ipNet != nil {
|
||||
ips = append(ips, ipNet.IP)
|
||||
}
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
// LXCGetIPsFromConfig returns IP addresses from container config.
|
||||
// Works for stopped/offline containers.
|
||||
func (node *Node) LXCGetIPsFromConfig(ctx context.Context, vmid int) ([]net.IP, error)
|
||||
```
|
||||
|
||||
### Check Container Status
|
||||
### Container Stats (like `docker stats`)
|
||||
|
||||
```go
|
||||
func (node *Node) LXCIsRunning(ctx context.Context, vmid int) (bool, error) {
|
||||
var status struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
err := node.Get(ctx, "/lxc/"+strconv.Itoa(vmid)+"/status/current", &status)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return status.Status == "running", nil
|
||||
}
|
||||
// LXCStats streams container statistics.
|
||||
// Format: "STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O"
|
||||
// Example: "running|31.1%|9.6GiB/20GiB|48.87%|4.7GiB/3.3GiB|25GiB/36GiB"
|
||||
func (node *Node) LXCStats(ctx context.Context, vmid int, stream bool) (io.ReadCloser, error)
|
||||
```
|
||||
|
||||
### Start Container
|
||||
### Container Command Execution
|
||||
|
||||
```go
|
||||
func (node *Node) LXCAction(ctx context.Context, vmid int, action string) error {
|
||||
return node.Post(ctx,
|
||||
"/lxc/"+strconv.Itoa(vmid)+"/status/"+action,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
// LXCCommand executes a command inside a container and streams output.
|
||||
func (node *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.ReadCloser, error)
|
||||
|
||||
// LXCJournalctl streams journalctl output for a container service.
|
||||
// On non-systemd systems, it falls back to tailing /var/log/messages.
|
||||
func (node *Node) LXCJournalctl(ctx context.Context, vmid int, services []string, limit int) (io.ReadCloser, error)
|
||||
|
||||
// LXCTail streams tail output for the given file.
|
||||
func (node *Node) LXCTail(ctx context.Context, vmid int, files []string, limit int) (io.ReadCloser, error)
|
||||
```
|
||||
|
||||
## Node Stats
|
||||
|
||||
```go
|
||||
type NodeStats struct {
|
||||
KernelVersion string `json:"kernel_version"`
|
||||
PVEVersion string `json:"pve_version"`
|
||||
CPUUsage string `json:"cpu_usage"`
|
||||
CPUModel string `json:"cpu_model"`
|
||||
MemUsage string `json:"mem_usage"`
|
||||
MemTotal string `json:"mem_total"`
|
||||
MemPct string `json:"mem_pct"`
|
||||
RootFSUsage string `json:"rootfs_usage"`
|
||||
RootFSTotal string `json:"rootfs_total"`
|
||||
RootFSPct string `json:"rootfs_pct"`
|
||||
Uptime string `json:"uptime"`
|
||||
LoadAvg1m string `json:"load_avg_1m"`
|
||||
LoadAvg5m string `json:"load_avg_5m"`
|
||||
LoadAvg15m string `json:"load_avg_15m"`
|
||||
}
|
||||
|
||||
const LXCStart = "start"
|
||||
// NodeStats streams node statistics like docker stats.
|
||||
func (n *Node) NodeStats(ctx context.Context, stream bool) (io.ReadCloser, error)
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
@@ -218,6 +364,13 @@ sequenceDiagram
|
||||
Node->>ProxmoxAPI: POST /lxc/{vmid}/status/start
|
||||
ProxmoxAPI-->>Node: Success
|
||||
Node-->>User: Done
|
||||
|
||||
User->>Node: LXCCommand(vmid, "df -h")
|
||||
Node->>ProxmoxAPI: WebSocket /nodes/{node}/termproxy
|
||||
ProxmoxAPI-->>Node: WebSocket connection
|
||||
Node->>ProxmoxAPI: Send: "pct exec {vmid} -- df -h"
|
||||
ProxmoxAPI-->>Node: Command output stream
|
||||
Node-->>User: Stream output
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -228,11 +381,38 @@ sequenceDiagram
|
||||
providers:
|
||||
proxmox:
|
||||
- url: https://proxmox.example.com:8006
|
||||
# Token-based authentication (optional)
|
||||
token_id: user@pam!token-name
|
||||
secret: your-api-token-secret
|
||||
|
||||
# Username/Password authentication (required for journalctl (service logs) streaming)
|
||||
# username: root
|
||||
# password: your-password
|
||||
# realm: pam
|
||||
|
||||
no_tls_verify: false
|
||||
```
|
||||
|
||||
### Authentication Options
|
||||
|
||||
```go
|
||||
// Token-based authentication (recommended)
|
||||
opts := []proxmox.Option{
|
||||
proxmox.WithAPIToken(c.TokenID, c.Secret.String()),
|
||||
proxmox.WithHTTPClient(&http.Client{Transport: tr}),
|
||||
}
|
||||
|
||||
// Username/Password authentication
|
||||
opts := []proxmox.Option{
|
||||
proxmox.WithCredentials(&proxmox.Credentials{
|
||||
Username: c.Username,
|
||||
Password: c.Password.String(),
|
||||
Realm: c.Realm,
|
||||
}),
|
||||
proxmox.WithHTTPClient(&http.Client{Transport: tr}),
|
||||
}
|
||||
```
|
||||
|
||||
### TLS Configuration
|
||||
|
||||
```go
|
||||
@@ -291,16 +471,16 @@ if r.Idlewatcher != nil && r.Idlewatcher.Proxmox != nil {
|
||||
|
||||
## Authentication
|
||||
|
||||
The package uses API tokens for authentication:
|
||||
The package supports two authentication methods:
|
||||
|
||||
```go
|
||||
opts := []proxmox.Option{
|
||||
proxmox.WithAPIToken(c.TokenID, c.Secret.String()),
|
||||
proxmox.WithHTTPClient(&http.Client{
|
||||
Transport: tr,
|
||||
}),
|
||||
}
|
||||
```
|
||||
1. **API Token** (recommended): Uses `token_id` and `secret`
|
||||
2. **Username/Password**: Uses `username`, `password`, and `realm`
|
||||
|
||||
Username/password authentication is required for:
|
||||
|
||||
- WebSocket connections (command execution, journalctl streaming)
|
||||
|
||||
Both methods support TLS verification options.
|
||||
|
||||
## Error Handling
|
||||
|
||||
@@ -312,11 +492,60 @@ if errors.Is(err, context.DeadlineExceeded) {
|
||||
|
||||
// Connection errors
|
||||
return gperr.New("failed to fetch proxmox cluster info").With(err)
|
||||
|
||||
// Resource not found
|
||||
return gperr.New("resource not found").With(ErrResourceNotFound)
|
||||
|
||||
// No session (for WebSocket operations)
|
||||
return gperr.New("no session").With(ErrNoSession)
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrResourceNotFound = errors.New("resource not found")
|
||||
ErrNoResources = errors.New("no resources")
|
||||
ErrNoSession = fmt.Errorf("no session found, make sure username and password are set")
|
||||
)
|
||||
```
|
||||
|
||||
| Error | Description |
|
||||
| --------------------- | --------------------------------------------------------------------- |
|
||||
| `ErrResourceNotFound` | Resource not found in cluster |
|
||||
| `ErrNoResources` | No resources available |
|
||||
| `ErrNoSession` | No session for WebSocket operations (requires username/password auth) |
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Cluster info fetched once on init
|
||||
- Nodes cached in pool
|
||||
- Per-operation API calls
|
||||
- 3-second timeout for initial connection
|
||||
- Resources updated in background loop (every 3 seconds by default)
|
||||
- Concurrent IP resolution for all containers (limited to GOMAXPROCS \* 2)
|
||||
- 5-second timeout for initial connection
|
||||
- Per-operation API calls with 3-second timeout
|
||||
- WebSocket connections properly closed to prevent goroutine leaks
|
||||
|
||||
## Command Validation
|
||||
|
||||
Commands executed via WebSocket are validated to prevent command injection. Invalid characters include:
|
||||
|
||||
```
|
||||
& | $ ; ' " ` $( ${ < >
|
||||
```
|
||||
|
||||
Services and files passed to `journalctl` and `tail` commands are automatically validated.
|
||||
|
||||
## Constants
|
||||
|
||||
```go
|
||||
const ResourcePollInterval = 3 * time.Second
|
||||
const SessionRefreshInterval = 1 * time.Minute
|
||||
const NodeStatsPollInterval = time.Second
|
||||
```
|
||||
|
||||
| Constant | Default | Description |
|
||||
| ------------------------ | ------- | ---------------------------------- |
|
||||
| `ResourcePollInterval` | 3s | How often VM resources are updated |
|
||||
| `SessionRefreshInterval` | 1m | How often sessions are refreshed |
|
||||
| `NodeStatsPollInterval` | 1s | How often node stats are streamed |
|
||||
|
||||
@@ -2,37 +2,196 @@ package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/luthermonson/go-proxmox"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
*proxmox.Client
|
||||
proxmox.Cluster
|
||||
*proxmox.Cluster
|
||||
Version *proxmox.Version
|
||||
BaseURL *url.URL
|
||||
// id -> resource; id: lxc/<vmid> or qemu/<vmid>
|
||||
resources map[string]*VMResource
|
||||
resourcesMu sync.RWMutex
|
||||
}
|
||||
|
||||
type VMResource struct {
|
||||
*proxmox.ClusterResource
|
||||
IPs []net.IP
|
||||
}
|
||||
|
||||
var (
|
||||
ErrResourceNotFound = errors.New("resource not found")
|
||||
ErrNoResources = errors.New("no resources")
|
||||
)
|
||||
|
||||
func NewClient(baseUrl string, opts ...proxmox.Option) *Client {
|
||||
return &Client{Client: proxmox.NewClient(baseUrl, opts...)}
|
||||
return &Client{
|
||||
Client: proxmox.NewClient(baseUrl, opts...),
|
||||
resources: make(map[string]*VMResource),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) UpdateClusterInfo(ctx context.Context) (err error) {
|
||||
baseURL, err := url.Parse(c.Client.GetBaseURL())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.BaseURL = baseURL
|
||||
c.Version, err = c.Client.Version(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// requires (/, Sys.Audit)
|
||||
if err := c.Get(ctx, "/cluster/status", &c.Cluster); err != nil {
|
||||
cluster, err := c.Client.Cluster(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Cluster = cluster
|
||||
|
||||
for _, node := range c.Cluster.Nodes {
|
||||
Nodes.Add(&Node{name: node.Name, id: node.ID, client: c.Client})
|
||||
Nodes.Add(NewNode(c, node.Name, node.ID))
|
||||
}
|
||||
if cluster.Name == "" && len(c.Cluster.Nodes) == 1 {
|
||||
cluster.Name = c.Cluster.Nodes[0].Name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateResources(ctx context.Context) error {
|
||||
if c.Cluster == nil {
|
||||
return errors.New("cluster not initialized, call UpdateClusterInfo first")
|
||||
}
|
||||
resourcesSlice, err := c.Cluster.Resources(ctx, "vm")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vmResources := make([]*VMResource, len(resourcesSlice))
|
||||
for i, resource := range resourcesSlice {
|
||||
vmResources[i] = &VMResource{
|
||||
ClusterResource: resource,
|
||||
IPs: nil,
|
||||
}
|
||||
}
|
||||
var errs errgroup.Group
|
||||
errs.SetLimit(runtime.GOMAXPROCS(0) * 2)
|
||||
for i, resource := range resourcesSlice {
|
||||
vmResource := vmResources[i]
|
||||
errs.Go(func() error {
|
||||
node, ok := Nodes.Get(resource.Node)
|
||||
if !ok {
|
||||
return fmt.Errorf("node %s not found", resource.Node)
|
||||
}
|
||||
vmid, ok := strings.CutPrefix(resource.ID, "lxc/")
|
||||
if !ok {
|
||||
return nil // not a lxc resource
|
||||
}
|
||||
vmidInt, err := strconv.Atoi(vmid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid resource id %s: %w", resource.ID, err)
|
||||
}
|
||||
ips, err := node.LXCGetIPs(ctx, vmidInt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get ips for resource %s: %w", resource.ID, err)
|
||||
}
|
||||
vmResource.IPs = ips
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := errs.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.resourcesMu.Lock()
|
||||
clear(c.resources)
|
||||
for i, resource := range resourcesSlice {
|
||||
c.resources[resource.ID] = vmResources[i]
|
||||
}
|
||||
c.resourcesMu.Unlock()
|
||||
log.Debug().Str("cluster", c.Cluster.Name).Msgf("[proxmox] updated %d resources", len(c.resources))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetResource gets a resource by kind and id.
|
||||
// kind: lxc or qemu
|
||||
// id: <vmid>
|
||||
func (c *Client) GetResource(kind string, id int) (*VMResource, error) {
|
||||
c.resourcesMu.RLock()
|
||||
defer c.resourcesMu.RUnlock()
|
||||
resource, ok := c.resources[kind+"/"+strconv.Itoa(id)]
|
||||
if !ok {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
// ReverseLookupResource looks up a resource by ip address, hostname, alias or all of them
|
||||
func (c *Client) ReverseLookupResource(ip net.IP, hostname string, alias string) (*VMResource, error) {
|
||||
c.resourcesMu.RLock()
|
||||
defer c.resourcesMu.RUnlock()
|
||||
|
||||
shouldCheckIP := ip != nil && !ip.IsLoopback() && !ip.IsUnspecified()
|
||||
shouldCheckHostname := hostname != ""
|
||||
shouldCheckAlias := alias != ""
|
||||
|
||||
if shouldCheckHostname {
|
||||
hostname, _, _ = strings.Cut(hostname, ".")
|
||||
}
|
||||
|
||||
for _, resource := range c.resources {
|
||||
if shouldCheckIP && slices.ContainsFunc(resource.IPs, func(a net.IP) bool { return a.Equal(ip) }) {
|
||||
return resource, nil
|
||||
}
|
||||
if shouldCheckHostname && resource.Name == hostname {
|
||||
return resource, nil
|
||||
}
|
||||
if shouldCheckAlias && resource.Name == alias {
|
||||
return resource, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
|
||||
// ReverseLookupNode looks up a node by name or IP address.
|
||||
// Returns the node name if found.
|
||||
func (c *Client) ReverseLookupNode(hostname string, ip net.IP, alias string) string {
|
||||
shouldCheckHostname := hostname != ""
|
||||
shouldCheckIP := ip != nil && !ip.IsLoopback() && !ip.IsUnspecified()
|
||||
shouldCheckAlias := alias != ""
|
||||
|
||||
if shouldCheckHostname {
|
||||
hostname, _, _ = strings.Cut(hostname, ".")
|
||||
}
|
||||
|
||||
for _, node := range c.Cluster.Nodes {
|
||||
if shouldCheckHostname && node.Name == hostname {
|
||||
return node.Name
|
||||
}
|
||||
if shouldCheckIP {
|
||||
nodeIP := net.ParseIP(node.IP)
|
||||
if nodeIP != nil && nodeIP.Equal(ip) {
|
||||
return node.Name
|
||||
}
|
||||
}
|
||||
if shouldCheckAlias && node.Name == alias {
|
||||
return node.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Key implements pool.Object
|
||||
func (c *Client) Key() string {
|
||||
return c.Cluster.ID
|
||||
|
||||
60
internal/proxmox/command_common.go
Normal file
60
internal/proxmox/command_common.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// checkValidInput checks if the input contains invalid characters.
|
||||
//
|
||||
// The characters are: & | $ ; ' " ` $( ${ < >
|
||||
// These characters are used in the command line to escape the input or to expand variables.
|
||||
// We need to check if the input contains these characters and return an error if it does.
|
||||
// This is to prevent command injection.
|
||||
func checkValidInput(input string) error {
|
||||
if strings.ContainsAny(input, "&|$;'\"`<>") {
|
||||
return fmt.Errorf("input contains invalid characters: %q", input)
|
||||
}
|
||||
if strings.Contains(input, "$(") {
|
||||
return fmt.Errorf("input contains $(: %q", input)
|
||||
}
|
||||
if strings.Contains(input, "${") {
|
||||
return fmt.Errorf("input contains ${: %q", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTail(files []string, limit int) (string, error) {
|
||||
for _, file := range files {
|
||||
if err := checkValidInput(file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
var command strings.Builder
|
||||
command.WriteString("tail -f -q ")
|
||||
for _, file := range files {
|
||||
fmt.Fprintf(&command, " %q ", file)
|
||||
}
|
||||
if limit > 0 {
|
||||
fmt.Fprintf(&command, " -n %d", limit)
|
||||
}
|
||||
// try --retry first, if it fails, try the command again
|
||||
return fmt.Sprintf("sh -c '%s --retry 2>/dev/null || %s'", command.String(), command.String()), nil
|
||||
}
|
||||
|
||||
func formatJournalctl(services []string, limit int) (string, error) {
|
||||
for _, service := range services {
|
||||
if err := checkValidInput(service); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
var command strings.Builder
|
||||
command.WriteString("journalctl -f")
|
||||
for _, service := range services {
|
||||
fmt.Fprintf(&command, " -u %q ", service)
|
||||
}
|
||||
if limit > 0 {
|
||||
fmt.Fprintf(&command, " -n %d", limit)
|
||||
}
|
||||
return command.String(), nil
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luthermonson/go-proxmox"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/godoxy/internal/net/gphttp"
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
strutils "github.com/yusing/goutils/strings"
|
||||
@@ -17,14 +19,24 @@ import (
|
||||
type Config struct {
|
||||
URL string `json:"url" validate:"required,url"`
|
||||
|
||||
TokenID string `json:"token_id" validate:"required"`
|
||||
Secret strutils.Redacted `json:"secret" validate:"required"`
|
||||
Username string `json:"username" validate:"required_without_all=TokenID Secret"`
|
||||
Password strutils.Redacted `json:"password" validate:"required_without_all=TokenID Secret"`
|
||||
Realm string `json:"realm"` // default is "pam"
|
||||
|
||||
TokenID string `json:"token_id" validate:"required_without_all=Username Password"`
|
||||
Secret strutils.Redacted `json:"secret" validate:"required_without_all=Username Password"`
|
||||
|
||||
NoTLSVerify bool `json:"no_tls_verify" yaml:"no_tls_verify,omitempty"`
|
||||
|
||||
client *Client
|
||||
}
|
||||
|
||||
const ResourcePollInterval = 3 * time.Second
|
||||
const SessionRefreshInterval = 1 * time.Minute
|
||||
|
||||
// NodeStatsPollInterval controls how often node stats are streamed when streaming is enabled.
|
||||
const NodeStatsPollInterval = time.Second
|
||||
|
||||
func (c *Config) Client() *Client {
|
||||
if c.client == nil {
|
||||
panic("proxmox client accessed before init")
|
||||
@@ -49,21 +61,105 @@ func (c *Config) Init(ctx context.Context) gperr.Error {
|
||||
}
|
||||
|
||||
opts := []proxmox.Option{
|
||||
proxmox.WithAPIToken(c.TokenID, c.Secret.String()),
|
||||
proxmox.WithHTTPClient(&http.Client{
|
||||
Transport: tr,
|
||||
}),
|
||||
}
|
||||
useCredentials := false
|
||||
if c.Username != "" && c.Password != "" {
|
||||
if c.Realm == "" {
|
||||
c.Realm = "pam"
|
||||
}
|
||||
opts = append(opts, proxmox.WithCredentials(&proxmox.Credentials{
|
||||
Username: c.Username,
|
||||
Password: c.Password.String(),
|
||||
Realm: c.Realm,
|
||||
}))
|
||||
useCredentials = true
|
||||
} else {
|
||||
opts = append(opts, proxmox.WithAPIToken(c.TokenID, c.Secret.String()))
|
||||
}
|
||||
c.client = NewClient(c.URL, opts...)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
initCtx, initCtxCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer initCtxCancel()
|
||||
|
||||
if err := c.client.UpdateClusterInfo(ctx); err != nil {
|
||||
if useCredentials {
|
||||
err := c.client.CreateSession(initCtx)
|
||||
if err != nil {
|
||||
return gperr.New("failed to create session").With(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.client.UpdateClusterInfo(initCtx); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return gperr.New("timeout fetching proxmox cluster info")
|
||||
}
|
||||
return gperr.New("failed to fetch proxmox cluster info").With(err)
|
||||
}
|
||||
|
||||
{
|
||||
reqCtx, reqCtxCancel := context.WithTimeout(ctx, ResourcePollInterval)
|
||||
err := c.client.UpdateResources(reqCtx)
|
||||
reqCtxCancel()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("cluster", c.client.Cluster.Name).Msg("[proxmox] failed to update resources")
|
||||
}
|
||||
}
|
||||
|
||||
go c.updateResourcesLoop(ctx)
|
||||
go c.refreshSessionLoop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) updateResourcesLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(ResourcePollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Trace().Str("cluster", c.client.Cluster.Name).Msg("[proxmox] starting resources update loop")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Trace().Str("cluster", c.client.Cluster.Name).Msg("[proxmox] stopping resources update loop")
|
||||
return
|
||||
case <-ticker.C:
|
||||
reqCtx, reqCtxCancel := context.WithTimeout(ctx, ResourcePollInterval)
|
||||
err := c.client.UpdateResources(reqCtx)
|
||||
reqCtxCancel()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("cluster", c.client.Cluster.Name).Msg("[proxmox] failed to update resources")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) refreshSessionLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(SessionRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Trace().Str("cluster", c.client.Cluster.Name).Msg("[proxmox] starting session refresh loop")
|
||||
|
||||
numRetries := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Trace().Str("cluster", c.client.Cluster.Name).Msg("[proxmox] stopping session refresh loop")
|
||||
return
|
||||
case <-ticker.C:
|
||||
reqCtx, reqCtxCancel := context.WithTimeout(ctx, SessionRefreshInterval)
|
||||
err := c.client.RefreshSession(reqCtx)
|
||||
reqCtxCancel()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("cluster", c.client.Cluster.Name).Msg("[proxmox] failed to refresh session")
|
||||
// exponential backoff
|
||||
numRetries++
|
||||
backoff := time.Duration(min(math.Pow(2, float64(numRetries)), 10)) * time.Second
|
||||
ticker.Reset(backoff)
|
||||
} else {
|
||||
ticker.Reset(SessionRefreshInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (n *Node) LXCAction(ctx context.Context, vmid int, action LXCAction) error
|
||||
return err
|
||||
}
|
||||
|
||||
task := proxmox.NewTask(upid, n.client)
|
||||
task := proxmox.NewTask(upid, n.client.Client)
|
||||
checkTicker := time.NewTicker(proxmoxTaskCheckInterval)
|
||||
defer checkTicker.Stop()
|
||||
for {
|
||||
@@ -170,17 +170,17 @@ func getIPFromNet(s string) (res []net.IP) { // name:...,bridge:...,gw=..,ip=...
|
||||
}
|
||||
|
||||
// LXCGetIPs returns the ip addresses of the container
|
||||
// it first tries to get the ip addresses from the config
|
||||
// if that fails, it gets the ip addresses from the interfaces
|
||||
// it first tries to get the ip addresses from the interfaces
|
||||
// if that fails, it gets the ip addresses from the config (offline containers)
|
||||
func (n *Node) LXCGetIPs(ctx context.Context, vmid int) (res []net.IP, err error) {
|
||||
ips, err := n.LXCGetIPsFromConfig(ctx, vmid)
|
||||
ips, err := n.LXCGetIPsFromInterfaces(ctx, vmid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) > 0 {
|
||||
return ips, nil
|
||||
}
|
||||
ips, err = n.LXCGetIPsFromInterfaces(ctx, vmid)
|
||||
ips, err = n.LXCGetIPsFromConfig(ctx, vmid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
68
internal/proxmox/lxc_command.go
Normal file
68
internal/proxmox/lxc_command.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/luthermonson/go-proxmox"
|
||||
)
|
||||
|
||||
var ErrNoSession = fmt.Errorf("no session found, make sure username and password are set")
|
||||
|
||||
// closeTransportConnections forces close idle HTTP connections to prevent goroutine leaks.
|
||||
// This is needed because the go-proxmox library's TermWebSocket closer doesn't close
|
||||
// the underlying HTTP/2 connections, leaving goroutines stuck in writeLoop/readLoop.
|
||||
func closeTransportConnections(httpClient *http.Client) {
|
||||
if tr, ok := httpClient.Transport.(*http.Transport); ok {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// LXCCommand connects to the Proxmox VNC websocket and streams command output.
|
||||
// It returns an io.ReadCloser that streams the command output.
|
||||
func (n *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.ReadCloser, error) {
|
||||
node := proxmox.NewNode(n.client.Client, n.name)
|
||||
lxc, err := node.Container(ctx, vmid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get container: %w", err)
|
||||
}
|
||||
|
||||
if lxc.Status != "running" {
|
||||
return io.NopCloser(bytes.NewReader(fmt.Appendf(nil, "container %d is not running, status: %s\n", vmid, lxc.Status))), nil
|
||||
}
|
||||
|
||||
return n.NodeCommand(ctx, fmt.Sprintf("pct exec %d -- %s", vmid, command))
|
||||
}
|
||||
|
||||
// LXCJournalctl streams journalctl output for the given service.
|
||||
//
|
||||
// On non systemd systems, it will tail /var/log/messages as fallback.
|
||||
//
|
||||
// If services are not empty, it will be used to filter the output by service.
|
||||
// If limit is greater than 0, it will be used to limit the number of lines of output.
|
||||
func (n *Node) LXCJournalctl(ctx context.Context, vmid int, services []string, limit int) (io.ReadCloser, error) {
|
||||
command, err := formatJournalctl(services, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(services) == 0 {
|
||||
// add /var/log/messages fallback for non systemd systems
|
||||
// in tail command, try --retry first, if it fails, try the command again
|
||||
command = fmt.Sprintf("sh -c '%s 2>/dev/null || tail -f -q --retry /var/log/messages 2>/dev/null || tail -f -q /var/log/messages'", command)
|
||||
}
|
||||
return n.LXCCommand(ctx, vmid, command)
|
||||
}
|
||||
|
||||
// LXCTail streams tail output for the given file.
|
||||
//
|
||||
// If limit is greater than 0, it will be used to limit the number of lines of output.
|
||||
func (n *Node) LXCTail(ctx context.Context, vmid int, files []string, limit int) (io.ReadCloser, error) {
|
||||
command, err := formatTail(files, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n.LXCCommand(ctx, vmid, command)
|
||||
}
|
||||
171
internal/proxmox/lxc_stats.go
Normal file
171
internal/proxmox/lxc_stats.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// const statsScriptLocation = "/tmp/godoxy-stats.sh"
|
||||
|
||||
// const statsScript = `#!/bin/sh
|
||||
|
||||
// # LXCStats script, written by godoxy.
|
||||
// printf "%s|%s|%s|%s|%s\n" \
|
||||
// "$(top -bn1 | grep "Cpu(s)" | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print 100 - $1"%"}')" \
|
||||
// "$(free -b | awk 'NR==2{printf "%.0f\n%.0f", $3, $2}' | numfmt --to=iec-i --suffix=B | paste -sd/)" \
|
||||
// "$(free | awk 'NR==2{printf "%.2f%%", $3/$2*100}')" \
|
||||
// "$(awk 'NR>2{r+=$2;t+=$10}END{printf "%.0f\n%.0f", r, t}' /proc/net/dev | numfmt --to=iec-i --suffix=B | paste -sd/)" \
|
||||
// "$(awk '{r+=$6;w+=$10}END{printf "%.0f\n%.0f", r*512, w*512}' /proc/diskstats | numfmt --to=iec-i --suffix=B | paste -sd/)"`
|
||||
|
||||
// var statsScriptBase64 = base64.StdEncoding.EncodeToString([]byte(statsScript))
|
||||
|
||||
// var statsInitCommand = fmt.Sprintf("sh -c 'echo %s | base64 -d > %s && chmod +x %s'", statsScriptBase64, statsScriptLocation, statsScriptLocation)
|
||||
|
||||
// var statsStreamScript = fmt.Sprintf("watch -t -w -p -n1 '%s'", statsScriptLocation)
|
||||
// var statsNonStreamScript = statsScriptLocation
|
||||
|
||||
// lxcStatsScriptInit initializes the stats script for the given container.
|
||||
// func (n *Node) lxcStatsScriptInit(ctx context.Context, vmid int) error {
|
||||
// reader, err := n.LXCCommand(ctx, vmid, statsInitCommand)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("failed to execute stats init command: %w", err)
|
||||
// }
|
||||
// reader.Close()
|
||||
// return nil
|
||||
// }
|
||||
|
||||
// LXCStats streams container stats, like docker stats.
|
||||
//
|
||||
// - format: "STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O"
|
||||
// - example: running|31.1%|9.6GiB/20GiB|48.87%|4.7GiB/3.3GiB|25GiB/36GiB
|
||||
func (n *Node) LXCStats(ctx context.Context, vmid int, stream bool) (io.ReadCloser, error) {
|
||||
if !stream {
|
||||
resource, err := n.client.GetResource("lxc", vmid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := writeLXCStatsLine(resource, &buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(&buf), nil
|
||||
}
|
||||
|
||||
// Validate the resource exists before returning a stream.
|
||||
_, err := n.client.GetResource("lxc", vmid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
interval := ResourcePollInterval
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
|
||||
go func() {
|
||||
writeSample := func() error {
|
||||
resource, err := n.client.GetResource("lxc", vmid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = writeLXCStatsLine(resource, pw)
|
||||
return err
|
||||
}
|
||||
|
||||
// Match `watch` behavior: write immediately, then on each tick.
|
||||
if err := writeSample(); err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = pw.CloseWithError(ctx.Err())
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := writeSample(); err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
func writeLXCStatsLine(resource *VMResource, w io.Writer) error {
|
||||
cpu := fmt.Sprintf("%.1f%%", resource.CPU*100)
|
||||
|
||||
memUsage := formatIECBytes(resource.Mem)
|
||||
memLimit := formatIECBytes(resource.MaxMem)
|
||||
memPct := "0.00%"
|
||||
if resource.MaxMem > 0 {
|
||||
memPct = fmt.Sprintf("%.2f%%", float64(resource.Mem)/float64(resource.MaxMem)*100)
|
||||
}
|
||||
|
||||
netIO := formatIECBytes(resource.NetIn) + "/" + formatIECBytes(resource.NetOut)
|
||||
blockIO := formatIECBytes(resource.DiskRead) + "/" + formatIECBytes(resource.DiskWrite)
|
||||
|
||||
// Keep the format consistent with LXCStatsAlt / `statsScript` (newline terminated).
|
||||
_, err := fmt.Fprintf(w, "%s|%s|%s/%s|%s|%s|%s\n", resource.Status, cpu, memUsage, memLimit, memPct, netIO, blockIO)
|
||||
return err
|
||||
}
|
||||
|
||||
// formatIECBytes formats a byte count using IEC binary prefixes (KiB, MiB, GiB, ...),
|
||||
// similar to `numfmt --to=iec-i --suffix=B`.
|
||||
func formatIECBytes(b uint64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%dB", b)
|
||||
}
|
||||
|
||||
prefixes := []string{"B", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei"}
|
||||
val := float64(b)
|
||||
exp := 0
|
||||
for val >= unit && exp < len(prefixes)-1 {
|
||||
val /= unit
|
||||
exp++
|
||||
}
|
||||
|
||||
// One decimal, trimming trailing ".0" to keep output compact (e.g. "10GiB").
|
||||
s := fmt.Sprintf("%.1f", val)
|
||||
s = strings.TrimSuffix(s, ".0")
|
||||
if exp == 0 {
|
||||
return s + "B"
|
||||
}
|
||||
return s + prefixes[exp] + "B"
|
||||
}
|
||||
|
||||
// LXCStatsAlt streams container stats, like docker stats.
|
||||
//
|
||||
// - format: "CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O"
|
||||
// - example: 31.1%|9.6GiB/20GiB|48.87%|4.7GiB/3.3GiB|25TiB/36TiB
|
||||
// func (n *Node) LXCStatsAlt(ctx context.Context, vmid int, stream bool) (io.ReadCloser, error) {
|
||||
// // Initialize the stats script if it hasn't been initialized yet.
|
||||
// initScriptErr, _ := n.statsScriptInitErrs.LoadOrCompute(vmid,
|
||||
// func() (newValue error, cancel bool) {
|
||||
// if err := n.lxcStatsScriptInit(ctx, vmid); err != nil {
|
||||
// cancel = errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
|
||||
// return err, cancel
|
||||
// }
|
||||
// return nil, false
|
||||
// })
|
||||
|
||||
// if initScriptErr != nil {
|
||||
// return nil, initScriptErr
|
||||
// }
|
||||
// if stream {
|
||||
// return n.LXCCommand(ctx, vmid, statsStreamScript)
|
||||
// }
|
||||
// return n.LXCCommand(ctx, vmid, statsNonStreamScript)
|
||||
// }
|
||||
@@ -6,18 +6,53 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/luthermonson/go-proxmox"
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
"github.com/yusing/goutils/pool"
|
||||
)
|
||||
|
||||
type NodeConfig struct {
|
||||
Node string `json:"node"`
|
||||
VMID *int `json:"vmid"` // unset: auto discover; explicit 0: node-level route; >0: lxc/qemu resource route
|
||||
VMName string `json:"vmname,omitempty"`
|
||||
Services []string `json:"services,omitempty" aliases:"service"`
|
||||
Files []string `json:"files,omitempty" aliases:"file"`
|
||||
} // @name ProxmoxNodeConfig
|
||||
|
||||
type Node struct {
|
||||
name string
|
||||
id string // likely node/<name>
|
||||
client *proxmox.Client
|
||||
client *Client
|
||||
|
||||
// statsScriptInitErrs *xsync.Map[int, error]
|
||||
}
|
||||
|
||||
// Validate implements the serialization.CustomValidator interface.
|
||||
func (n *NodeConfig) Validate() gperr.Error {
|
||||
var errs gperr.Builder
|
||||
for i, service := range n.Services {
|
||||
if err := checkValidInput(service); err != nil {
|
||||
errs.AddSubjectf(err, "services[%d]", i)
|
||||
}
|
||||
}
|
||||
for i, file := range n.Files {
|
||||
if err := checkValidInput(file); err != nil {
|
||||
errs.AddSubjectf(err, "files[%d]", i)
|
||||
}
|
||||
}
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
var Nodes = pool.New[*Node]("proxmox_nodes")
|
||||
|
||||
func NewNode(client *Client, name, id string) *Node {
|
||||
return &Node{
|
||||
name: name,
|
||||
id: id,
|
||||
client: client,
|
||||
// statsScriptInitErrs: xsync.NewMap[int, error](xsync.WithGrowOnly()),
|
||||
}
|
||||
}
|
||||
|
||||
func AvailableNodeNames() string {
|
||||
if Nodes.Size() == 0 {
|
||||
return ""
|
||||
@@ -38,6 +73,10 @@ func (n *Node) Name() string {
|
||||
return n.name
|
||||
}
|
||||
|
||||
func (n *Node) Client() *Client {
|
||||
return n.client
|
||||
}
|
||||
|
||||
func (n *Node) String() string {
|
||||
return fmt.Sprintf("%s (%s)", n.name, n.id)
|
||||
}
|
||||
|
||||
144
internal/proxmox/node_command.go
Normal file
144
internal/proxmox/node_command.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/luthermonson/go-proxmox"
|
||||
)
|
||||
|
||||
// NodeCommand connects to the Proxmox VNC websocket and streams command output.
|
||||
// It returns an io.ReadCloser that streams the command output.
|
||||
func (n *Node) NodeCommand(ctx context.Context, command string) (io.ReadCloser, error) {
|
||||
if !n.client.HasSession() {
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
|
||||
node := proxmox.NewNode(n.client.Client, n.name)
|
||||
term, err := node.TermProxy(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get term proxy: %w", err)
|
||||
}
|
||||
|
||||
send, recv, errs, closeWS, err := node.TermWebSocket(term)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to term websocket: %w", err)
|
||||
}
|
||||
|
||||
// Wrap the websocket closer to also close HTTP transport connections.
|
||||
// This prevents goroutine leaks when streaming connections are interrupted.
|
||||
httpClient := n.client.GetHTTPClient()
|
||||
closeFn := func() error {
|
||||
closeTransportConnections(httpClient)
|
||||
return closeWS()
|
||||
}
|
||||
|
||||
handleSend := func(data []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case send <- data:
|
||||
return nil
|
||||
case err := <-errs:
|
||||
return fmt.Errorf("failed to send: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send command
|
||||
cmd := []byte(command + "\n")
|
||||
if err := handleSend(cmd); err != nil {
|
||||
closeFn()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create a pipe to stream the websocket messages
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
// Command line without trailing newline for matching in output
|
||||
cmdLine := cmd[:len(cmd)-1]
|
||||
|
||||
// Start a goroutine to read from websocket and write to pipe
|
||||
go func() {
|
||||
defer closeFn()
|
||||
defer pw.Close()
|
||||
|
||||
seenCommand := false
|
||||
shouldSkip := true
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = pw.CloseWithError(ctx.Err())
|
||||
return
|
||||
case msg := <-recv:
|
||||
// skip the header message like
|
||||
|
||||
// Linux pve 6.17.4-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.4-1 (2025-12-03T15:42Z) x86_64
|
||||
//
|
||||
// The programs included with the Debian GNU/Linux system are free software;
|
||||
// the exact distribution terms for each program are described in the
|
||||
// individual files in /usr/share/doc/*/copyright.
|
||||
//
|
||||
// Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
|
||||
// permitted by applicable law.
|
||||
//
|
||||
// root@pve:~# pct exec 101 -- journalctl -u "sftpgo" -f
|
||||
//
|
||||
// send begins after the line above
|
||||
if shouldSkip {
|
||||
// First, check if this message contains our command echo
|
||||
if !seenCommand && bytes.Contains(msg, cmdLine) {
|
||||
seenCommand = true
|
||||
}
|
||||
// Only stop skipping after we've seen the command AND output markers
|
||||
if seenCommand {
|
||||
if bytes.Contains(msg, []byte("\x1b[H")) || // watch cursor home
|
||||
bytes.Contains(msg, []byte("\x1b[?2004l")) { // bracket paste OFF (command ended)
|
||||
shouldSkip = false
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := pw.Write(msg); err != nil {
|
||||
return
|
||||
}
|
||||
case err := <-errs:
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
return
|
||||
}
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// NodeJournalctl streams journalctl output for the given service.
|
||||
//
|
||||
// If services are not empty, it will be used to filter the output by services.
|
||||
// If limit is greater than 0, it will be used to limit the number of lines of output.
|
||||
func (n *Node) NodeJournalctl(ctx context.Context, services []string, limit int) (io.ReadCloser, error) {
|
||||
command, err := formatJournalctl(services, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n.NodeCommand(ctx, command)
|
||||
}
|
||||
|
||||
// NodeTail streams tail output for the given file.
|
||||
//
|
||||
// If limit is greater than 0, it will be used to limit the number of lines of output.
|
||||
func (n *Node) NodeTail(ctx context.Context, files []string, limit int) (io.ReadCloser, error) {
|
||||
command, err := formatTail(files, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n.NodeCommand(ctx, command)
|
||||
}
|
||||
143
internal/proxmox/node_stats.go
Normal file
143
internal/proxmox/node_stats.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NodeStats struct {
|
||||
KernelVersion string `json:"kernel_version"`
|
||||
PVEVersion string `json:"pve_version"`
|
||||
CPUUsage string `json:"cpu_usage"`
|
||||
CPUModel string `json:"cpu_model"`
|
||||
MemUsage string `json:"mem_usage"`
|
||||
MemTotal string `json:"mem_total"`
|
||||
MemPct string `json:"mem_pct"`
|
||||
RootFSUsage string `json:"rootfs_usage"`
|
||||
RootFSTotal string `json:"rootfs_total"`
|
||||
RootFSPct string `json:"rootfs_pct"`
|
||||
Uptime string `json:"uptime"`
|
||||
LoadAvg1m string `json:"load_avg_1m"`
|
||||
LoadAvg5m string `json:"load_avg_5m"`
|
||||
LoadAvg15m string `json:"load_avg_15m"`
|
||||
}
|
||||
|
||||
// NodeStats streams node stats, like docker stats.
|
||||
func (n *Node) NodeStats(ctx context.Context, stream bool) (io.ReadCloser, error) {
|
||||
if !stream {
|
||||
var buf bytes.Buffer
|
||||
if err := n.writeNodeStatsLine(ctx, &buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(&buf), nil
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
go func() {
|
||||
writeSample := func() error {
|
||||
return n.writeNodeStatsLine(ctx, pw)
|
||||
}
|
||||
|
||||
// Match `watch` behavior: write immediately, then on each tick.
|
||||
if err := writeSample(); err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(NodeStatsPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = pw.CloseWithError(ctx.Err())
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := writeSample(); err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
func (n *Node) writeNodeStatsLine(ctx context.Context, w io.Writer) error {
|
||||
// Fetch node status for CPU and memory metrics.
|
||||
node, err := n.client.Node(ctx, n.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cpu := fmt.Sprintf("%.1f%%", node.CPU*100)
|
||||
|
||||
memUsage := formatIECBytes(node.Memory.Used)
|
||||
memTotal := formatIECBytes(node.Memory.Total)
|
||||
memPct := "0.00%"
|
||||
if node.Memory.Total > 0 {
|
||||
memPct = fmt.Sprintf("%.2f%%", float64(node.Memory.Used)/float64(node.Memory.Total)*100)
|
||||
}
|
||||
|
||||
rootFSUsage := formatIECBytes(node.RootFS.Used)
|
||||
rootFSTotal := formatIECBytes(node.RootFS.Total)
|
||||
rootFSPct := "0.00%"
|
||||
if node.RootFS.Total > 0 {
|
||||
rootFSPct = fmt.Sprintf("%.2f%%", float64(node.RootFS.Used)/float64(node.RootFS.Total)*100)
|
||||
}
|
||||
|
||||
uptime := formatDuration(node.Uptime)
|
||||
|
||||
if len(node.LoadAvg) != 3 {
|
||||
return fmt.Errorf("unexpected load average length: %d, expected 3 (1m, 5m, 15m)", len(node.LoadAvg))
|
||||
}
|
||||
|
||||
// Linux 6.17.4-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.4-1 (2025-12-03T15:42Z)
|
||||
// => 6.17.4-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.4-1 (2025-12-03T15:42Z)
|
||||
kversion, _ := strings.CutPrefix(node.Kversion, "Linux ")
|
||||
// => 6.17.4-1-pve
|
||||
kversion, _, _ = strings.Cut(kversion, " ")
|
||||
|
||||
nodeStats := NodeStats{
|
||||
KernelVersion: kversion,
|
||||
PVEVersion: node.PVEVersion,
|
||||
CPUUsage: cpu,
|
||||
CPUModel: node.CPUInfo.Model,
|
||||
MemUsage: memUsage,
|
||||
MemTotal: memTotal,
|
||||
MemPct: memPct,
|
||||
RootFSUsage: rootFSUsage,
|
||||
RootFSTotal: rootFSTotal,
|
||||
RootFSPct: rootFSPct,
|
||||
Uptime: uptime,
|
||||
LoadAvg1m: node.LoadAvg[0],
|
||||
LoadAvg5m: node.LoadAvg[1],
|
||||
LoadAvg15m: node.LoadAvg[2],
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(nodeStats)
|
||||
return err
|
||||
}
|
||||
|
||||
// formatDuration formats uptime in seconds to a human-readable string.
|
||||
func formatDuration(seconds uint64) string {
|
||||
if seconds < 60 {
|
||||
return fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
days := seconds / 86400
|
||||
hours := (seconds % 86400) / 3600
|
||||
mins := (seconds % 3600) / 60
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd%dh%dm", days, hours, mins)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh%dm", hours, mins)
|
||||
}
|
||||
return fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
56
internal/proxmox/validation_test.go
Normal file
56
internal/proxmox/validation_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
)
|
||||
|
||||
func TestValidateCommandArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlCfg string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid_services",
|
||||
yamlCfg: `services: ["foo", "bar"]`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid_services",
|
||||
yamlCfg: `services: ["foo", "bar & baz"]`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid_services_with_$(",
|
||||
yamlCfg: `services: ["foo", "bar & $(echo 'hello')"]`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid_files",
|
||||
yamlCfg: `files: ["foo", "bar"]`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid_files",
|
||||
yamlCfg: `files: ["foo", "bar & baz"]`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var cfg NodeConfig
|
||||
err := serialization.UnmarshalValidate([]byte(tt.yamlCfg), &cfg, yaml.Unmarshal)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "input contains invalid characters")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
@@ -43,7 +44,7 @@ func removeXPrefix(m map[string]any) gperr.Error {
|
||||
}
|
||||
|
||||
func validate(data []byte) (routes route.Routes, err gperr.Error) {
|
||||
err = serialization.UnmarshalValidateYAMLIntercept(data, &routes, removeXPrefix)
|
||||
err = serialization.UnmarshalValidate(data, &routes, yaml.Unmarshal, removeXPrefix)
|
||||
return routes, err
|
||||
}
|
||||
|
||||
|
||||
@@ -64,10 +64,12 @@ type (
|
||||
AccessLog *accesslog.RequestLoggerConfig `json:"access_log,omitempty" extensions:"x-nullable"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
|
||||
Proxmox *proxmox.NodeConfig `json:"proxmox,omitempty" extensions:"x-nullable"`
|
||||
|
||||
Idlewatcher *types.IdlewatcherConfig `json:"idlewatcher,omitempty" extensions:"x-nullable"`
|
||||
|
||||
Metadata `deserialize:"-"`
|
||||
}
|
||||
} // @name Route
|
||||
|
||||
Metadata struct {
|
||||
/* Docker only */
|
||||
@@ -130,6 +132,10 @@ func (r Routes) Contains(alias string) bool {
|
||||
}
|
||||
|
||||
func (r *Route) Validate() gperr.Error {
|
||||
// wait for alias to be set
|
||||
if r.Alias == "" {
|
||||
return nil
|
||||
}
|
||||
// pcs := make([]uintptr, 1)
|
||||
// runtime.Callers(2, pcs)
|
||||
// f := runtime.FuncForPC(pcs[0])
|
||||
@@ -176,69 +182,143 @@ func (r *Route) validate() gperr.Error {
|
||||
}
|
||||
}, r.started)
|
||||
|
||||
if r.Idlewatcher != nil && r.Idlewatcher.Proxmox != nil {
|
||||
node := r.Idlewatcher.Proxmox.Node
|
||||
vmid := r.Idlewatcher.Proxmox.VMID
|
||||
if node == "" {
|
||||
return gperr.Errorf("node (proxmox node name) is required")
|
||||
if r.Proxmox != nil && r.Idlewatcher != nil {
|
||||
r.Idlewatcher.Proxmox = &types.ProxmoxConfig{
|
||||
Node: r.Proxmox.Node,
|
||||
}
|
||||
if vmid <= 0 {
|
||||
return gperr.Errorf("vmid (lxc id) is required")
|
||||
if r.Proxmox.VMID != nil {
|
||||
r.Idlewatcher.Proxmox.VMID = *r.Proxmox.VMID
|
||||
}
|
||||
if r.Host == DefaultHost {
|
||||
containerName := r.Idlewatcher.ContainerName()
|
||||
// get ip addresses of the vmid
|
||||
node, ok := proxmox.Nodes.Get(node)
|
||||
if !ok {
|
||||
return gperr.Errorf("proxmox node %s not found in pool", node)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if r.Proxmox == nil && r.Idlewatcher != nil && r.Idlewatcher.Proxmox != nil {
|
||||
r.Proxmox = &proxmox.NodeConfig{
|
||||
Node: r.Idlewatcher.Proxmox.Node,
|
||||
VMID: &r.Idlewatcher.Proxmox.VMID,
|
||||
}
|
||||
}
|
||||
|
||||
ips, err := node.LXCGetIPs(ctx, vmid)
|
||||
if err != nil {
|
||||
return gperr.Errorf("failed to get ip addresses of vmid %d: %w", vmid, err)
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return gperr.Multiline().
|
||||
Addf("no ip addresses found for %s", containerName).
|
||||
Adds("make sure you have set static ip address for container instead of dhcp").
|
||||
Subject(containerName)
|
||||
}
|
||||
|
||||
l := log.With().Str("container", containerName).Logger()
|
||||
|
||||
l.Info().Msg("checking if container is running")
|
||||
running, err := node.LXCIsRunning(ctx, vmid)
|
||||
if err != nil {
|
||||
return gperr.New("failed to check container state").With(err)
|
||||
}
|
||||
|
||||
if !running {
|
||||
l.Info().Msg("starting container")
|
||||
if err := node.LXCAction(ctx, vmid, proxmox.LXCStart); err != nil {
|
||||
return gperr.New("failed to start container").With(err)
|
||||
if (r.Proxmox == nil || r.Proxmox.Node == "" || r.Proxmox.VMID == nil) && r.Container == nil {
|
||||
proxmoxProviders := config.WorkingState.Load().Value().Providers.Proxmox
|
||||
if len(proxmoxProviders) > 0 {
|
||||
// it's fine if ip is nil
|
||||
hostname := r.Host
|
||||
ip := net.ParseIP(hostname)
|
||||
for _, p := range proxmoxProviders {
|
||||
// First check if hostname, IP, or alias matches a node (node-level route)
|
||||
if nodeName := p.Client().ReverseLookupNode(hostname, ip, r.Alias); nodeName != "" {
|
||||
zero := 0
|
||||
if r.Proxmox == nil {
|
||||
r.Proxmox = &proxmox.NodeConfig{}
|
||||
}
|
||||
r.Proxmox.Node = nodeName
|
||||
r.Proxmox.VMID = &zero
|
||||
r.Proxmox.VMName = ""
|
||||
log.Info().
|
||||
Str("node", nodeName).
|
||||
Msgf("found proxmox node for route %q", r.Alias)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
l.Info().Msgf("finding reachable ip addresses")
|
||||
errs := gperr.NewBuilder("failed to find reachable ip addresses")
|
||||
for _, ip := range ips {
|
||||
if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil {
|
||||
errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy))
|
||||
} else {
|
||||
r.Host = ip.String()
|
||||
l.Info().Msgf("using ip %s", r.Host)
|
||||
// Then check if hostname, IP, or alias matches a VM resource
|
||||
resource, _ := p.Client().ReverseLookupResource(ip, hostname, r.Alias)
|
||||
if resource != nil {
|
||||
vmid := int(resource.VMID)
|
||||
if r.Proxmox == nil {
|
||||
r.Proxmox = &proxmox.NodeConfig{}
|
||||
}
|
||||
r.Proxmox.Node = resource.Node
|
||||
r.Proxmox.VMID = &vmid
|
||||
r.Proxmox.VMName = resource.Name
|
||||
log.Info().
|
||||
Str("node", resource.Node).
|
||||
Int("vmid", int(resource.VMID)).
|
||||
Str("vmname", resource.Name).
|
||||
Msgf("found proxmox resource for route %q", r.Alias)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.Proxmox != nil {
|
||||
nodeName := r.Proxmox.Node
|
||||
vmid := r.Proxmox.VMID
|
||||
if nodeName == "" || vmid == nil {
|
||||
return gperr.Errorf("node (proxmox node name) is required")
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(nodeName)
|
||||
if !ok {
|
||||
return gperr.Errorf("proxmox node %s not found in pool", nodeName)
|
||||
}
|
||||
|
||||
// Node-level route (VMID = 0)
|
||||
if *vmid == 0 {
|
||||
r.Scheme = route.SchemeHTTPS
|
||||
if r.Host == DefaultHost {
|
||||
return gperr.Multiline().
|
||||
Addf("no reachable ip addresses found, tried %d IPs", len(ips)).
|
||||
With(errs.Error()).
|
||||
Subject(containerName)
|
||||
r.Host = node.Client().BaseURL.Hostname()
|
||||
}
|
||||
port, _ := strconv.Atoi(node.Client().BaseURL.Port())
|
||||
if port == 0 {
|
||||
port = 8006
|
||||
}
|
||||
r.Port.Proxy = port
|
||||
} else {
|
||||
res, err := node.Client().GetResource("lxc", *vmid)
|
||||
if err != nil {
|
||||
return gperr.Wrap(err) // ErrResourceNotFound
|
||||
}
|
||||
|
||||
r.Proxmox.VMName = res.Name
|
||||
|
||||
if r.Host == DefaultHost {
|
||||
containerName := res.Name
|
||||
// get ip addresses of the vmid
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ips := res.IPs
|
||||
if len(ips) == 0 {
|
||||
return gperr.Multiline().
|
||||
Addf("no ip addresses found for %s", containerName).
|
||||
Adds("make sure you have set static ip address for container instead of dhcp").
|
||||
Subject(containerName)
|
||||
}
|
||||
|
||||
l := log.With().Str("container", containerName).Logger()
|
||||
|
||||
l.Info().Msg("checking if container is running")
|
||||
running, err := node.LXCIsRunning(ctx, *vmid)
|
||||
if err != nil {
|
||||
return gperr.New("failed to check container state").With(err)
|
||||
}
|
||||
|
||||
if !running {
|
||||
l.Info().Msg("starting container")
|
||||
if err := node.LXCAction(ctx, *vmid, proxmox.LXCStart); err != nil {
|
||||
return gperr.New("failed to start container").With(err)
|
||||
}
|
||||
}
|
||||
|
||||
l.Info().Msgf("finding reachable ip addresses")
|
||||
errs := gperr.NewBuilder("failed to find reachable ip addresses")
|
||||
for _, ip := range ips {
|
||||
if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil {
|
||||
errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy))
|
||||
} else {
|
||||
r.Host = ip.String()
|
||||
l.Info().Msgf("using ip %s", r.Host)
|
||||
break
|
||||
}
|
||||
}
|
||||
if r.Host == DefaultHost {
|
||||
return gperr.Multiline().
|
||||
Addf("no reachable ip addresses found, tried %d IPs", len(ips)).
|
||||
With(errs.Error()).
|
||||
Subject(containerName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,11 +565,24 @@ func (r *Route) References() []string {
|
||||
}
|
||||
|
||||
if r.Container != nil {
|
||||
if r.Container.ContainerName != r.Alias {
|
||||
if r.Container.ContainerName != aliasRef {
|
||||
return []string{r.Container.ContainerName, aliasRef, r.Container.Image.Name, r.Container.Image.Author}
|
||||
}
|
||||
return []string{r.Container.Image.Name, aliasRef, r.Container.Image.Author}
|
||||
}
|
||||
|
||||
if r.Proxmox != nil {
|
||||
if len(r.Proxmox.Services) > 0 && r.Proxmox.Services[0] != aliasRef {
|
||||
if r.Proxmox.VMName != aliasRef {
|
||||
return []string{r.Proxmox.VMName, aliasRef, r.Proxmox.Services[0]}
|
||||
}
|
||||
return []string{r.Proxmox.Services[0], aliasRef}
|
||||
} else {
|
||||
if r.Proxmox.VMName != aliasRef {
|
||||
return []string{r.Proxmox.VMName, aliasRef}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []string{aliasRef}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,43 @@ func GetHealthInfoSimple() map[string]types.HealthStatus
|
||||
func ByProvider() map[string][]types.Route
|
||||
```
|
||||
|
||||
## Proxmox Integration
|
||||
|
||||
Routes can be automatically linked to Proxmox nodes or LXC containers through reverse lookup during validation.
|
||||
|
||||
### Node-Level Routes
|
||||
|
||||
Routes can be linked to a Proxmox node directly (VMID = 0) when the route's hostname, IP, or alias matches a node name or IP:
|
||||
|
||||
```go
|
||||
// Route linked to Proxmox node (no specific VM)
|
||||
route.Proxmox = &proxmox.NodeConfig{
|
||||
Node: "pve-node-01",
|
||||
VMID: 0, // node-level, no container
|
||||
VMName: "",
|
||||
}
|
||||
```
|
||||
|
||||
### Container-Level Routes
|
||||
|
||||
Routes are linked to LXC containers when they match a VM resource by hostname, IP, or alias:
|
||||
|
||||
```go
|
||||
// Route linked to LXC container
|
||||
route.Proxmox = &proxmox.NodeConfig{
|
||||
Node: "pve-node-01",
|
||||
VMID: 100,
|
||||
VMName: "my-container",
|
||||
}
|
||||
```
|
||||
|
||||
### Lookup Priority
|
||||
|
||||
1. **Node match** - If hostname, IP, or alias matches a Proxmox node
|
||||
2. **VM match** - If hostname, IP, or alias matches a VM resource
|
||||
|
||||
Node-level routes skip container control logic (start/check IPs) and can be used to proxy node services directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
@@ -174,10 +211,11 @@ sequenceDiagram
|
||||
|
||||
## Dependency and Integration Map
|
||||
|
||||
| Dependency | Purpose |
|
||||
| -------------------------------- | --------------------------------- |
|
||||
| `internal/types` | Route and health type definitions |
|
||||
| `github.com/yusing/goutils/pool` | Thread-safe pool implementation |
|
||||
| Dependency | Purpose |
|
||||
| -------------------------------- | ---------------------------------- |
|
||||
| `internal/types` | Route and health type definitions |
|
||||
| `internal/proxmox` | Proxmox node/container integration |
|
||||
| `github.com/yusing/goutils/pool` | Thread-safe pool implementation |
|
||||
|
||||
## Observability
|
||||
|
||||
|
||||
@@ -78,3 +78,14 @@ func Get(alias string) (types.Route, bool) {
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetIncludeExcluded returns the route with the given alias, including excluded routes.
|
||||
func GetIncludeExcluded(alias string) (types.Route, bool) {
|
||||
if r, ok := HTTP.Get(alias); ok {
|
||||
return r, true
|
||||
}
|
||||
if r, ok := Stream.Get(alias); ok {
|
||||
return r, true
|
||||
}
|
||||
return Excluded.Get(alias)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ var commands = map[string]struct {
|
||||
},
|
||||
build: func(args any) CommandHandler {
|
||||
return NonTerminatingCommand(func(w http.ResponseWriter, r *http.Request) error {
|
||||
if authHandler == nil {
|
||||
http.Error(w, "Auth handler not initialized", http.StatusInternalServerError)
|
||||
return errTerminated
|
||||
}
|
||||
if !authHandler(w, r) {
|
||||
return errTerminated
|
||||
}
|
||||
|
||||
37
internal/serialization/gin_binding.go
Normal file
37
internal/serialization/gin_binding.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
type (
|
||||
GinJSONBinding struct{}
|
||||
GinYAMLBinding struct{}
|
||||
)
|
||||
|
||||
func (b GinJSONBinding) Name() string {
|
||||
return "json"
|
||||
}
|
||||
|
||||
func (b GinJSONBinding) Bind(req *http.Request, obj any) error {
|
||||
m := make(map[string]any)
|
||||
if err := sonic.ConfigDefault.NewDecoder(NewSubstituteEnvReader(req.Body)).Decode(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
return MapUnmarshalValidate(m, obj)
|
||||
}
|
||||
|
||||
func (b GinYAMLBinding) Name() string {
|
||||
return "yaml"
|
||||
}
|
||||
|
||||
func (b GinYAMLBinding) Bind(req *http.Request, obj any) error {
|
||||
m := make(map[string]any)
|
||||
if err := yaml.NewDecoder(NewSubstituteEnvReader(req.Body)).Decode(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
return MapUnmarshalValidate(m, obj)
|
||||
}
|
||||
50
internal/serialization/gin_binding_test.go
Normal file
50
internal/serialization/gin_binding_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package serialization_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
)
|
||||
|
||||
type TestStruct struct {
|
||||
Value string `json:"value"`
|
||||
Value2 int `json:"value2"`
|
||||
}
|
||||
|
||||
func (t *TestStruct) Validate() gperr.Error {
|
||||
if t.Value == "" {
|
||||
return gperr.New("value is required")
|
||||
}
|
||||
if t.Value2 != 0 && (t.Value2 < 5 || t.Value2 > 10) {
|
||||
return gperr.New("value2 must be between 5 and 10")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGinBinding(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid1", `{"value": "test", "value2": 7}`, false},
|
||||
{"valid2", `{"value": "test"}`, false},
|
||||
{"invalid1", `{"value2": 7}`, true},
|
||||
{"invalid2", `{"value": "test", "value2": 3}`, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var dst TestStruct
|
||||
body := bytes.NewBufferString(tt.input)
|
||||
req := httptest.NewRequest("POST", "/", body)
|
||||
err := serialization.GinJSONBinding{}.Bind(req, &dst)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("%s: Bind() error = %v, wantErr %v", tt.name, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
146
internal/serialization/reader.go
Normal file
146
internal/serialization/reader.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
)
|
||||
|
||||
type SubstituteEnvReader struct {
|
||||
reader io.Reader
|
||||
buf []byte // buffered data with substitutions applied
|
||||
err error // sticky error
|
||||
}
|
||||
|
||||
func NewSubstituteEnvReader(reader io.Reader) *SubstituteEnvReader {
|
||||
return &SubstituteEnvReader{reader: reader}
|
||||
}
|
||||
|
||||
const peekSize = 4096
|
||||
const maxVarNameLength = 256
|
||||
|
||||
func (r *SubstituteEnvReader) Read(p []byte) (n int, err error) {
|
||||
// Return buffered data first
|
||||
if len(r.buf) > 0 {
|
||||
n = copy(p, r.buf)
|
||||
r.buf = r.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Return sticky error if we have one
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
var buf [2 * peekSize]byte
|
||||
|
||||
// Read a chunk from the underlying reader
|
||||
chunk, more := buf[:peekSize], buf[peekSize:]
|
||||
nRead, readErr := r.reader.Read(chunk)
|
||||
if nRead == 0 {
|
||||
if readErr != nil {
|
||||
return 0, readErr
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
chunk = chunk[:nRead]
|
||||
|
||||
// Check if there's a potential incomplete pattern at the end
|
||||
// Pattern: ${VAR_NAME}
|
||||
// We need to check if chunk ends with a partial pattern like "$", "${", "${VAR", etc.
|
||||
incompleteStart := findIncompletePatternStart(chunk)
|
||||
|
||||
if incompleteStart >= 0 && readErr == nil {
|
||||
// There might be an incomplete pattern, read more to complete it
|
||||
incomplete := chunk[incompleteStart:]
|
||||
chunk = chunk[:incompleteStart]
|
||||
|
||||
// Keep reading until we complete the pattern or hit EOF/error
|
||||
for {
|
||||
// Limit how much we buffer to prevent memory exhaustion
|
||||
if len(incomplete) > maxVarNameLength+3 { // ${} + var name
|
||||
// Pattern too long to be valid, give up and process as-is
|
||||
chunk = append(chunk, incomplete...)
|
||||
break
|
||||
}
|
||||
nMore, moreErr := r.reader.Read(more)
|
||||
if nMore > 0 {
|
||||
incomplete = append(incomplete, more[:nMore]...)
|
||||
// Check if pattern is now complete
|
||||
if idx := bytes.IndexByte(incomplete, '}'); idx >= 0 {
|
||||
// Pattern complete, append the rest back to chunk
|
||||
chunk = append(chunk, incomplete...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if moreErr != nil {
|
||||
// No more data, append whatever we have
|
||||
chunk = append(chunk, incomplete...)
|
||||
readErr = moreErr
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
substituted, subErr := substituteEnv(chunk)
|
||||
if subErr != nil {
|
||||
r.err = subErr
|
||||
return 0, subErr
|
||||
}
|
||||
|
||||
n = copy(p, substituted)
|
||||
if n < len(substituted) {
|
||||
// Buffer the rest
|
||||
r.buf = substituted[n:]
|
||||
}
|
||||
|
||||
// Store sticky error for next read
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
r.err = readErr
|
||||
} else {
|
||||
if readErr == io.EOF && n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
return n, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// findIncompletePatternStart returns the index where an incomplete ${...} pattern starts,
|
||||
// or -1 if there's no incomplete pattern at the end.
|
||||
func findIncompletePatternStart(data []byte) int {
|
||||
// Look for '$' near the end that might be start of ${VAR}
|
||||
// Maximum var name we reasonably expect + "${}" = ~256 chars
|
||||
searchStart := max(0, len(data)-maxVarNameLength)
|
||||
|
||||
for i := len(data) - 1; i >= searchStart; i-- {
|
||||
if data[i] == '$' {
|
||||
// Check if this is a complete pattern or incomplete
|
||||
if i+1 >= len(data) {
|
||||
// Just "$" at end
|
||||
return i
|
||||
}
|
||||
if data[i+1] == '{' {
|
||||
// Check if there's anything after "${"
|
||||
if i+2 >= len(data) {
|
||||
// Just "${" at end
|
||||
return i
|
||||
}
|
||||
// Check if pattern is complete by looking for '}'
|
||||
for j := i + 2; j < len(data); j++ {
|
||||
if data[j] == '}' {
|
||||
// This pattern is complete, continue searching for another
|
||||
break
|
||||
}
|
||||
if j == len(data)-1 {
|
||||
// Reached end without finding '}', incomplete pattern
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
286
internal/serialization/reader_bench_test.go
Normal file
286
internal/serialization/reader_bench_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupEnv sets up environment variables for benchmarks
|
||||
func setupEnv(b *testing.B) {
|
||||
b.Helper()
|
||||
os.Setenv("BENCH_VAR", "benchmark_value")
|
||||
os.Setenv("BENCH_VAR_2", "second_value")
|
||||
os.Setenv("BENCH_VAR_3", "third_value")
|
||||
}
|
||||
|
||||
// cleanupEnv cleans up environment variables after benchmarks
|
||||
func cleanupEnv(b *testing.B) {
|
||||
b.Helper()
|
||||
os.Unsetenv("BENCH_VAR")
|
||||
os.Unsetenv("BENCH_VAR_2")
|
||||
os.Unsetenv("BENCH_VAR_3")
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_NoSubstitution benchmarks reading without any env substitutions
|
||||
func BenchmarkSubstituteEnvReader_NoSubstitution(b *testing.B) {
|
||||
r := strings.NewReader(`key: value
|
||||
name: test
|
||||
data: some content here
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_SingleSubstitution benchmarks reading with a single env substitution
|
||||
func BenchmarkSubstituteEnvReader_SingleSubstitution(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
r := strings.NewReader(`key: ${BENCH_VAR}
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_MultipleSubstitutions benchmarks reading with multiple env substitutions
|
||||
func BenchmarkSubstituteEnvReader_MultipleSubstitutions(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
r := strings.NewReader(`key1: ${BENCH_VAR}
|
||||
key2: ${BENCH_VAR_2}
|
||||
key3: ${BENCH_VAR_3}
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_LargeInput_NoSubstitution benchmarks large input without substitutions
|
||||
func BenchmarkSubstituteEnvReader_LargeInput_NoSubstitution(b *testing.B) {
|
||||
r := strings.NewReader(strings.Repeat("x", 100000))
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_LargeInput_WithSubstitutions benchmarks large input with scattered substitutions
|
||||
func BenchmarkSubstituteEnvReader_LargeInput_WithSubstitutions(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
var builder bytes.Buffer
|
||||
for range 100 {
|
||||
builder.WriteString(strings.Repeat("x", 1000))
|
||||
builder.WriteString("${BENCH_VAR}")
|
||||
}
|
||||
r := bytes.NewReader(builder.Bytes())
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_SmallBuffer benchmarks reading with a small buffer size
|
||||
func BenchmarkSubstituteEnvReader_SmallBuffer(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
r := strings.NewReader(`key: ${BENCH_VAR} and some more content here`)
|
||||
buf := make([]byte, 16)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
for {
|
||||
_, err := reader.Read(buf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_YAMLConfig benchmarks a realistic YAML config scenario
|
||||
func BenchmarkSubstituteEnvReader_YAMLConfig(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
r := strings.NewReader(`database:
|
||||
host: ${BENCH_VAR}
|
||||
port: ${BENCH_VAR_2}
|
||||
username: ${BENCH_VAR_3}
|
||||
password: ${BENCH_VAR}
|
||||
cache:
|
||||
enabled: true
|
||||
ttl: ${BENCH_VAR_2}
|
||||
server:
|
||||
host: ${BENCH_VAR}
|
||||
port: 8080
|
||||
`)
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_BoundaryPattern benchmarks patterns at buffer boundaries (4096 bytes)
|
||||
func BenchmarkSubstituteEnvReader_BoundaryPattern(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
// Pattern exactly at 4090 bytes, with ${VAR} crossing the 4096 boundary
|
||||
prefix := strings.Repeat("x", 4090)
|
||||
r := strings.NewReader(prefix + "${BENCH_VAR}")
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_MultipleBoundaries benchmarks multiple patterns crossing boundaries
|
||||
func BenchmarkSubstituteEnvReader_MultipleBoundaries(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
var builder bytes.Buffer
|
||||
for range 10 {
|
||||
builder.WriteString(strings.Repeat("x", 4000))
|
||||
builder.WriteString("${BENCH_VAR}")
|
||||
}
|
||||
r := bytes.NewReader(builder.Bytes())
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_SpecialChars benchmarks substitution with special characters
|
||||
func BenchmarkSubstituteEnvReader_SpecialChars(b *testing.B) {
|
||||
os.Setenv("SPECIAL_BENCH_VAR", `value with "quotes" and \backslash\`)
|
||||
defer os.Unsetenv("SPECIAL_BENCH_VAR")
|
||||
|
||||
r := strings.NewReader(`key: ${SPECIAL_BENCH_VAR}
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_EmptyValue benchmarks substitution with empty value
|
||||
func BenchmarkSubstituteEnvReader_EmptyValue(b *testing.B) {
|
||||
os.Setenv("EMPTY_BENCH_VAR", "")
|
||||
defer os.Unsetenv("EMPTY_BENCH_VAR")
|
||||
|
||||
r := strings.NewReader(`key: ${EMPTY_BENCH_VAR}
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_DollarWithoutBrace benchmarks $ without following {
|
||||
func BenchmarkSubstituteEnvReader_DollarWithoutBrace(b *testing.B) {
|
||||
os.Setenv("BENCH_VAR", "benchmark_value")
|
||||
defer os.Unsetenv("BENCH_VAR")
|
||||
|
||||
r := strings.NewReader(`price: $100 and $200 for ${BENCH_VAR}`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFindIncompletePatternStart benchmarks the findIncompletePatternStart function
|
||||
func BenchmarkFindIncompletePatternStart(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"no pattern", strings.Repeat("hello world ", 100)},
|
||||
{"complete pattern", strings.Repeat("hello ${VAR} world ", 50)},
|
||||
{"dollar at end", strings.Repeat("hello ", 100) + "$"},
|
||||
{"incomplete at end", strings.Repeat("hello ", 100) + "${VAR"},
|
||||
{"large input no pattern", strings.Repeat("x", 5000)},
|
||||
{"large input with pattern", strings.Repeat("x", 4000) + "${VAR}"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
data := []byte(tc.input)
|
||||
for b.Loop() {
|
||||
findIncompletePatternStart(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
217
internal/serialization/reader_test.go
Normal file
217
internal/serialization/reader_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSubstituteEnvReader_Basic(t *testing.T) {
|
||||
os.Setenv("TEST_VAR", "hello")
|
||||
defer os.Unsetenv("TEST_VAR")
|
||||
|
||||
input := []byte(`key: ${TEST_VAR}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: "hello"`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_Multiple(t *testing.T) {
|
||||
os.Setenv("VAR1", "first")
|
||||
os.Setenv("VAR2", "second")
|
||||
defer os.Unsetenv("VAR1")
|
||||
defer os.Unsetenv("VAR2")
|
||||
|
||||
input := []byte(`a: ${VAR1}, b: ${VAR2}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `a: "first", b: "second"`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_NoSubstitution(t *testing.T) {
|
||||
input := []byte(`key: value`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: value`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_UnsetEnvError(t *testing.T) {
|
||||
os.Unsetenv("UNSET_VAR_FOR_TEST")
|
||||
|
||||
input := []byte(`key: ${UNSET_VAR_FOR_TEST}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
_, err := io.ReadAll(reader)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "UNSET_VAR_FOR_TEST is not set")
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_SmallBuffer(t *testing.T) {
|
||||
os.Setenv("SMALL_BUF_VAR", "value")
|
||||
defer os.Unsetenv("SMALL_BUF_VAR")
|
||||
|
||||
input := []byte(`key: ${SMALL_BUF_VAR}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
var result []byte
|
||||
buf := make([]byte, 3)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
result = append(result, buf[:n]...)
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, `key: "value"`, string(result))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_SpecialChars(t *testing.T) {
|
||||
os.Setenv("SPECIAL_VAR", `hello "world" \n`)
|
||||
defer os.Unsetenv("SPECIAL_VAR")
|
||||
|
||||
input := []byte(`key: ${SPECIAL_VAR}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: "hello \"world\" \\n"`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_EmptyValue(t *testing.T) {
|
||||
os.Setenv("EMPTY_VAR", "")
|
||||
defer os.Unsetenv("EMPTY_VAR")
|
||||
|
||||
input := []byte(`key: ${EMPTY_VAR}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: ""`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_LargeInput(t *testing.T) {
|
||||
os.Setenv("LARGE_VAR", "replaced")
|
||||
defer os.Unsetenv("LARGE_VAR")
|
||||
|
||||
prefix := strings.Repeat("x", 5000)
|
||||
suffix := strings.Repeat("y", 5000)
|
||||
input := []byte(prefix + "${LARGE_VAR}" + suffix)
|
||||
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
expected := prefix + `"replaced"` + suffix
|
||||
require.Equal(t, expected, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_PatternAtBoundary(t *testing.T) {
|
||||
os.Setenv("BOUNDARY_VAR", "boundary_value")
|
||||
defer os.Unsetenv("BOUNDARY_VAR")
|
||||
|
||||
prefix := strings.Repeat("a", 4090)
|
||||
input := []byte(prefix + "${BOUNDARY_VAR}")
|
||||
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
expected := prefix + `"boundary_value"`
|
||||
require.Equal(t, expected, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_MultiplePatternsBoundary(t *testing.T) {
|
||||
os.Setenv("VAR_A", "aaa")
|
||||
os.Setenv("VAR_B", "bbb")
|
||||
defer os.Unsetenv("VAR_A")
|
||||
defer os.Unsetenv("VAR_B")
|
||||
|
||||
prefix := strings.Repeat("x", 4090)
|
||||
input := []byte(prefix + "${VAR_A} middle ${VAR_B}")
|
||||
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
expected := prefix + `"aaa" middle "bbb"`
|
||||
require.Equal(t, expected, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_YAMLConfig(t *testing.T) {
|
||||
os.Setenv("DB_HOST", "localhost")
|
||||
os.Setenv("DB_PORT", "5432")
|
||||
os.Setenv("DB_PASSWORD", "secret123")
|
||||
defer os.Unsetenv("DB_HOST")
|
||||
defer os.Unsetenv("DB_PORT")
|
||||
defer os.Unsetenv("DB_PASSWORD")
|
||||
|
||||
input := []byte(`database:
|
||||
host: ${DB_HOST}
|
||||
port: ${DB_PORT}
|
||||
password: ${DB_PASSWORD}
|
||||
`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
expected := `database:
|
||||
host: "localhost"
|
||||
port: "5432"
|
||||
password: "secret123"
|
||||
`
|
||||
require.Equal(t, expected, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_DollarWithoutBrace(t *testing.T) {
|
||||
input := []byte(`key: $NOT_A_PATTERN`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: $NOT_A_PATTERN`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_EmptyInput(t *testing.T) {
|
||||
input := []byte(``)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ``, string(output))
|
||||
}
|
||||
|
||||
func TestFindIncompletePatternStart(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected int
|
||||
}{
|
||||
{"no pattern", "hello world", -1},
|
||||
{"complete pattern", "hello ${VAR} world", -1},
|
||||
{"dollar at end", "hello $", 6},
|
||||
{"dollar brace at end", "hello ${", 6},
|
||||
{"incomplete var at end", "hello ${VAR", 6},
|
||||
{"complete then incomplete", "hello ${VAR} ${INCOMPLETE", 13},
|
||||
{"multiple complete", "${A} ${B} ${C}", -1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := findIncompletePatternStart([]byte(tt.input))
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package serialization
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
@@ -85,6 +86,10 @@ func initPtr(dst reflect.Value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate performs struct validation using go-playground/validator tags.
|
||||
//
|
||||
// It collects all validation errors and returns them as a single error.
|
||||
// Field names in errors are prefixed with their namespace (e.g., "User.Email").
|
||||
func ValidateWithFieldTags(s any) gperr.Error {
|
||||
var errs gperr.Builder
|
||||
err := validate.Struct(s)
|
||||
@@ -257,10 +262,11 @@ func initTypeKeyFieldIndexesMap(t reflect.Type) typeInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// MapUnmarshalValidate takes a SerializedObject and a target value, and assigns the values in the SerializedObject to the target value.
|
||||
// MapUnmarshalValidate ignores case differences between the field names in the SerializedObject and the target.
|
||||
// MapUnmarshalValidate takes a SerializedObject and a target value,
|
||||
// and assigns the values in the SerializedObject to the target value.
|
||||
//
|
||||
// It ignores case differences between the field names in the SerializedObject and the target.
|
||||
//
|
||||
// The target value must be a struct or a map[string]any.
|
||||
// If the target value is a struct , and implements the MapUnmarshaller interface,
|
||||
// the UnmarshalMap method will be called.
|
||||
//
|
||||
@@ -455,6 +461,13 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.
|
||||
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
|
||||
}
|
||||
|
||||
// ConvertSlice converts a source slice to a destination slice.
|
||||
//
|
||||
// - Elements are converted one by one using the Convert function.
|
||||
// - Validation is performed on each element if checkValidateTag is true.
|
||||
// - The destination slice is initialized with the source length.
|
||||
// - On error, the destination slice is truncated to the number of
|
||||
// successfully converted elements.
|
||||
func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error {
|
||||
if dst.Kind() == reflect.Pointer {
|
||||
if dst.IsNil() && !dst.CanSet() {
|
||||
@@ -507,6 +520,12 @@ func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) g
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertString converts a string value to the destination reflect.Value.
|
||||
// - It handles various types including numeric types, booleans, time.Duration,
|
||||
// slices (comma-separated or YAML), maps, and structs (YAML).
|
||||
// - If the destination implements the Parser interface, it is used for conversion.
|
||||
// - Returns true if conversion was handled (even with error), false if
|
||||
// conversion is unsupported.
|
||||
func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) {
|
||||
convertible = true
|
||||
dstT := dst.Type()
|
||||
@@ -618,48 +637,80 @@ func substituteEnv(data []byte) ([]byte, gperr.Error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func UnmarshalValidateYAML[T any](data []byte, target *T) gperr.Error {
|
||||
type (
|
||||
marshalFunc func(src any) ([]byte, error)
|
||||
unmarshalFunc func(data []byte, target any) error
|
||||
newDecoderFunc func(r io.Reader) interface {
|
||||
Decode(v any) error
|
||||
}
|
||||
interceptFunc func(m map[string]any) gperr.Error
|
||||
)
|
||||
|
||||
// UnmarshalValidate unmarshals data into a map, applies optional intercept
|
||||
// functions, and validates the result against the target struct using field tags.
|
||||
// - Environment variables in the data are substituted using ${VAR} syntax.
|
||||
// - The unmarshaler function converts data to a map[string]any.
|
||||
// - Intercept functions can modify or validate the map before unmarshaling.
|
||||
func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) gperr.Error {
|
||||
data, err := substituteEnv(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m := make(map[string]any)
|
||||
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||
if err := unmarshaler(data, &m); err != nil {
|
||||
return gperr.Wrap(err)
|
||||
}
|
||||
for _, intercept := range interceptFns {
|
||||
if err := intercept(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return MapUnmarshalValidate(m, target)
|
||||
}
|
||||
|
||||
func UnmarshalValidateYAMLIntercept[T any](data []byte, target *T, intercept func(m map[string]any) gperr.Error) gperr.Error {
|
||||
// UnmarshalValidateReader reads from an io.Reader, unmarshals to a map,
|
||||
// - Applies optional intercept functions, and validates against the target struct.
|
||||
// - Environment variables are substituted during reading using ${VAR} syntax.
|
||||
// - The newDecoder function creates a decoder for the reader (e.g.,
|
||||
// json.NewDecoder).
|
||||
func UnmarshalValidateReader[T any](reader io.Reader, target *T, newDecoder newDecoderFunc, interceptFns ...interceptFunc) gperr.Error {
|
||||
m := make(map[string]any)
|
||||
if err := newDecoder(NewSubstituteEnvReader(reader)).Decode(&m); err != nil {
|
||||
return gperr.Wrap(err)
|
||||
}
|
||||
for _, intercept := range interceptFns {
|
||||
if err := intercept(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return MapUnmarshalValidate(m, target)
|
||||
}
|
||||
|
||||
// UnmarshalValidateXSync unmarshals data into an xsync.Map[string, V].
|
||||
// - Environment variables in the data are substituted using ${VAR} syntax.
|
||||
// - The unmarshaler function converts data to a map[string]any.
|
||||
// - Intercept functions can modify or validate the map before unmarshaling.
|
||||
// - Returns a thread-safe concurrent map with the unmarshaled values.
|
||||
func UnmarshalValidateXSync[V any](data []byte, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) (*xsync.Map[string, V], gperr.Error) {
|
||||
data, err := substituteEnv(data)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := make(map[string]any)
|
||||
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||
return gperr.Wrap(err)
|
||||
if err := unmarshaler(data, &m); err != nil {
|
||||
return nil, gperr.Wrap(err)
|
||||
}
|
||||
if err := intercept(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return MapUnmarshalValidate(m, target)
|
||||
}
|
||||
|
||||
func UnmarshalValidateYAMLXSync[V any](data []byte) (_ *xsync.Map[string, V], err gperr.Error) {
|
||||
data, err = substituteEnv(data)
|
||||
if err != nil {
|
||||
return
|
||||
for _, intercept := range interceptFns {
|
||||
if err := intercept(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
m := make(map[string]any)
|
||||
if err = gperr.Wrap(yaml.Unmarshal(data, &m)); err != nil {
|
||||
return
|
||||
}
|
||||
m2 := make(map[string]V, len(m))
|
||||
if err = MapUnmarshalValidate(m, m2); err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
ret := xsync.NewMap[string, V](xsync.WithPresize(len(m)))
|
||||
for k, v := range m2 {
|
||||
@@ -668,26 +719,27 @@ func UnmarshalValidateYAMLXSync[V any](data []byte) (_ *xsync.Map[string, V], er
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func loadSerialized[T any](path string, dst *T, deserialize func(data []byte, dst any) error) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return deserialize(data, dst)
|
||||
}
|
||||
|
||||
func SaveJSON[T any](path string, src *T, perm os.FileMode) error {
|
||||
data, err := sonic.Marshal(src)
|
||||
// SaveFile marshals a value to bytes and writes it to a file.
|
||||
// - The marshaler function converts the value to bytes.
|
||||
// - The file is written with the specified permissions.
|
||||
func SaveFile[T any](path string, src *T, perm os.FileMode, marshaler marshalFunc) error {
|
||||
data, err := marshaler(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
|
||||
func LoadJSONIfExist[T any](path string, dst *T) error {
|
||||
err := loadSerialized(path, dst, sonic.Unmarshal)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
// LoadFileIfExist reads a file and unmarshals its contents to a value.
|
||||
// - The unmarshaler function converts the bytes to a value.
|
||||
// - If the file does not exist, nil is returned and dst remains unchanged.
|
||||
func LoadFileIfExist[T any](path string, dst *T, unmarshaler unmarshalFunc) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
return unmarshaler(data, dst)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/require"
|
||||
expect "github.com/yusing/goutils/testing"
|
||||
)
|
||||
@@ -303,6 +304,6 @@ autocert:
|
||||
} `yaml:"options"`
|
||||
} `yaml:"autocert"`
|
||||
}
|
||||
require.NoError(t, UnmarshalValidateYAML(data, &cfg))
|
||||
require.NoError(t, UnmarshalValidate(data, &cfg, yaml.Unmarshal))
|
||||
require.Equal(t, "test", cfg.Autocert.Options.AuthToken)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package types
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
)
|
||||
@@ -10,14 +11,14 @@ import (
|
||||
func TestDockerProviderConfigUnmarshalMap(t *testing.T) {
|
||||
t.Run("string", func(t *testing.T) {
|
||||
var cfg map[string]*DockerProviderConfig
|
||||
err := serialization.UnmarshalValidateYAML([]byte("test: http://localhost:2375"), &cfg)
|
||||
err := serialization.UnmarshalValidate([]byte("test: http://localhost:2375"), &cfg, yaml.Unmarshal)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375"}, cfg["test"])
|
||||
})
|
||||
|
||||
t.Run("detailed", func(t *testing.T) {
|
||||
var cfg map[string]*DockerProviderConfig
|
||||
err := serialization.UnmarshalValidateYAML([]byte(`
|
||||
err := serialization.UnmarshalValidate([]byte(`
|
||||
test:
|
||||
scheme: http
|
||||
host: localhost
|
||||
@@ -25,7 +26,7 @@ test:
|
||||
tls:
|
||||
ca_file: /etc/ssl/ca.crt
|
||||
cert_file: /etc/ssl/cert.crt
|
||||
key_file: /etc/ssl/key.crt`), &cfg)
|
||||
key_file: /etc/ssl/key.crt`), &cfg, yaml.Unmarshal)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375", TLS: &DockerTLSConfig{CAFile: "/etc/ssl/ca.crt", CertFile: "/etc/ssl/cert.crt", KeyFile: "/etc/ssl/key.crt"}}, cfg["test"])
|
||||
})
|
||||
@@ -131,7 +132,7 @@ func TestDockerProviderConfigValidation(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var cfg map[string]*DockerProviderConfig
|
||||
err := serialization.UnmarshalValidateYAML([]byte(test.yamlStr), &cfg)
|
||||
err := serialization.UnmarshalValidate([]byte(test.yamlStr), &cfg, yaml.Unmarshal)
|
||||
if test.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
|
||||
@@ -41,11 +41,11 @@ type (
|
||||
DockerCfg DockerProviderConfig `json:"docker_cfg" validate:"required"`
|
||||
ContainerID string `json:"container_id" validate:"required"`
|
||||
ContainerName string `json:"container_name" validate:"required"`
|
||||
} // @name DockerConfig
|
||||
} // @name IdlewatcherDockerConfig
|
||||
ProxmoxConfig struct {
|
||||
Node string `json:"node" validate:"required"`
|
||||
VMID int `json:"vmid" validate:"required"`
|
||||
} // @name ProxmoxConfig
|
||||
} // @name IdlewatcherProxmoxNodeConfig
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -8,18 +8,17 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
configDirWatcher *DirWatcher
|
||||
configDirWatcherMu sync.Mutex
|
||||
configDirWatcher *DirWatcher
|
||||
configDirWatcherInitOnce sync.Once
|
||||
)
|
||||
|
||||
func initConfigDirWatcher() {
|
||||
t := task.RootTask("config_dir_watcher", false)
|
||||
configDirWatcher = NewDirectoryWatcher(t, common.ConfigBasePath)
|
||||
}
|
||||
|
||||
// create a new file watcher for file under ConfigBasePath.
|
||||
func NewConfigFileWatcher(filename string) Watcher {
|
||||
configDirWatcherMu.Lock()
|
||||
defer configDirWatcherMu.Unlock()
|
||||
|
||||
if configDirWatcher == nil {
|
||||
t := task.RootTask("config_dir_watcher", false)
|
||||
configDirWatcher = NewDirectoryWatcher(t, common.ConfigBasePath)
|
||||
}
|
||||
configDirWatcherInitOnce.Do(initConfigDirWatcher)
|
||||
return configDirWatcher.Add(filename)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type ImplDoc = {
|
||||
const START_MARKER = "// GENERATED-IMPL-SIDEBAR-START";
|
||||
const END_MARKER = "// GENERATED-IMPL-SIDEBAR-END";
|
||||
|
||||
const skipSubmodules = ["internal/go-oidc/", "internal/gopsutil/"];
|
||||
const skipSubmodules = ["internal/go-oidc/", "internal/gopsutil/", "internal/go-proxmox/"];
|
||||
|
||||
function escapeRegex(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -165,9 +165,8 @@ function rewriteImplMarkdown(params: {
|
||||
const repoRel = path.posix.normalize(
|
||||
path.posix.join(pkgPath, filePath)
|
||||
);
|
||||
const githubUrl = `${repoUrl}/blob/main/${repoRel}${
|
||||
line ? `#L${line}` : ""
|
||||
}`;
|
||||
const githubUrl = `${repoUrl}/blob/main/${repoRel}${line ? `#L${line}` : ""
|
||||
}`;
|
||||
const rewritten = `${githubUrl}${fragment}`;
|
||||
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
|
||||
@@ -14,8 +14,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 h1:w/bWkEJdYuRNYhHn5eXnIT8LzDM1O629X1I9MJSkD7Q=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
|
||||
Reference in New Issue
Block a user