Compare commits

..

56 Commits

Author SHA1 Message Date
yusing
59238adb5b fix(middleware): skip body rewriters when buffering fails
Prevent response modifiers that require body rewriting from running when
the body rewrite gate blocks buffering (for example, chunked transfer
encoding).

Add an explicit `requiresBodyRewrite` capability and implement it for
HTML/theme/error-page modifiers, including bypass delegation.

Also add a regression test to ensure the original response body remains
readable and is not closed prematurely when rewrite is blocked.

This commit fixeds the "http: read on closed response body" with empty page error
happens when body-rewriting middleware (like themed) runs on responses where body rewrite is blocked (e.g. chunked),
then the gate restores an already-closed original body.
2026-03-01 03:40:43 +08:00
yusing
5f48f141ca fix(systeminfo): Collect all partitions then filter partitions
Keep only real disk-like mounts (/, /dev/*,
and /mnt/* excluding /mnt/ itself and /mnt/wsl) to avoid noisy or irrelevant entries in
disk metrics.

Normalize disk map keys to use mountpoints for empty/none and /dev/root
devices so usage data remains stable and accessible across environments.
2026-02-28 18:16:04 +08:00
yusing
a0adc51269 feat(rules): support multiline or |
treat lines ending with unquoted `|` or `&` as continued
conditions in `do` block headers so nested blocks parse correctly
across line breaks.

update `on` condition splitting to avoid breaking on newlines that
follow an unescaped trailing pipe, while still respecting quotes,
escapes, and bracket nesting.

add coverage for multiline `|`/`&` continuations in `do` parsing,
`splitAnd`, `parseOn`, and HTTP flow nested block behavior.
2026-02-28 18:16:04 +08:00
yusing
c002055892 chore(rules): update example to use new block syntax 2026-02-28 18:16:03 +08:00
yusing
d5406fb039 doc(entrypoint): escape [] in mermaid 2026-02-28 18:16:03 +08:00
Jarek Krochmalski
1bd8b5a696 fix(middleware): restore SSE streaming for POST endpoints (regression in v0.27.0) (#206)
* fix(middleware): restore SSE streaming for POST endpoints

Regression introduced in 16935865 (v0.27.0).

Before that commit, LazyResponseModifier only buffered HTML responses and
let everything else pass through via the IsBuffered() early return. The
refactor replaced it with NewResponseModifier which unconditionally buffers
all writes until FlushRelease() fires after the handler returns. That kills
real-time streaming for any SSE endpoint that uses POST.

The existing bypass at ServeHTTP line 193 only fires when the *request*
carries Accept: text/event-stream. That works for browser EventSource (which
always sets that header) but not for programmatic fetch() calls, which set
Content-Type: application/json on the request and only emit
Content-Type: text/event-stream on the *response*.

Fix: introduce ssePassthroughWriter, a thin http.ResponseWriter wrapper that
sits in front of the ResponseModifier. It watches for Content-Type:
text/event-stream in the response headers at the moment WriteHeader or the
first Write is called. Once detected it copies the buffered headers to the
real writer and switches all subsequent writes to pass directly through with
an immediate Flush(), bypassing the ResponseModifier buffer entirely.

Also tighten the Accept header check from == to strings.Contains so that
Accept: text/event-stream, */* is handled correctly.

Reported against Dockhand (https://github.com/Finsys/dockhand) where
container update progress, image pull logs and vulnerability scan output all
stopped streaming after users upgraded to GoDoxy v0.27.0. GET SSE endpoints
(container logs) continued to work because browsers send Accept:
text/event-stream for EventSource connections.

* fix(middleware): make Content-Type SSE check case-insensitive

* refactor(middleware): extract Content-Type into a named constant

* fix(middleware): enhance safe guard to avoid buffering SSE, WS and large bodies

Reverts some changes in 16935865 and apply more rubust handling.

Use a lazy response modifier that buffers only when the response is safe
to mutate. This prevents middleware from intercepting websocket/SSE
streams, encoded payloads, and non-text or oversized responses.

Set a 4MB max buffered size and gate buffering via response headers
(content type, transfer/content encoding, and content length). Skip
mutation when a response is not buffered or mutation setup fails, and
simplify chained response modifiers to operate on the same response.

Also update the goutils submodule for max body limit support.

---------

Co-authored-by: yusing <yusing.wys@gmail.com>
2026-02-28 17:15:41 +08:00
yusing
79327e98bd chore: update submodule goutils 2026-02-26 14:19:03 +08:00
yusing
206f69d249 chore(scripts): fix docker depedencies in refresh-compat.sh 2026-02-26 01:14:04 +08:00
yusing
3f6b09d05e chore(scripts): narrow git diff to only include go files in refresh-compat.sh 2026-02-26 01:11:41 +08:00
yusing
af68eb4b18 build: create placeholder JS files and tidy Go modules
- Add script logic to create empty placeholder files for minified JS files
  so go vet won't complain about missing files
- Run go mod tidy in root and agent directory to clean up dependencies
2026-02-26 01:03:02 +08:00
yusing
9927267149 chore: improve refresh-compat.sh with error handling and fix sed application
Add `set -euo pipefail` for strict error handling, check for clean working tree before running, and add trap for cleanup. Move sed replacements from patch file to actual changed Go files to correctly apply sonic-to-json transformations after checkout.
2026-02-26 00:46:12 +08:00
yusing
af8cddc1b2 chore: add AGENTS.md 2026-02-26 00:39:10 +08:00
yusing
c74da5cba9 build: make POST_BUILD always run 2026-02-26 00:35:44 +08:00
yusing
c23cf8ef06 ci: refactor compat branch refresh to use patch-based approach 2026-02-26 00:34:48 +08:00
yusing
733716ba2b build(cli): fix build path and unify build command
Use the shared build target for CLI binaries and upload
artifacts to GitHub releases on tag builds.
2026-02-25 14:40:06 +08:00
yusing
0716d3dc0d chore(example): add netbird docker compose example 2026-02-25 12:09:05 +08:00
yusing
b64944cfc3 fix(rules): add nil guard to entrypoint retrieval in route command 2026-02-25 12:04:34 +08:00
yusing
5b068469ef fix(goutils/server): set ConnContext in http3 server to preserve context values
- This fixes http3 connections not able to retrieve entrypoint from context, and also causing panic in `route` rule command
2026-02-25 12:02:47 +08:00
yusing
6576b7640a chore(deps: update dependencies 2026-02-24 18:14:39 +08:00
yusing
d4e552754e fix(Makefile): update WEBUI_DIR and DOCS_DIR to use absolute paths
Changed WEBUI_DIR to resolve to an absolute path using the current working directory,
and updated DOCS_DIR to be relative to WEBUI_DIR
2026-02-24 18:12:47 +08:00
yusing
9ca2983a52 docs(rules): document new dynamic variables and redaction 2026-02-24 18:11:35 +08:00
yusing
ed2ca236b0 feat(api): accept rule config block string in playground
Update playground request to take rules as a string and parse
either YAML list or DSL config, with tests and swagger updates.
2026-02-24 18:11:17 +08:00
yusing
0eba045104 feat(rules): add $redacted dynamic variable for masking sensitive values
Introduces a new `$redacted` dynamic variable that wraps its single
argument with `strutils.Redact`, allowing sensitive values (e.g.,
authorization headers, query parameters) to be masked in rule
expressions.

The variable accepts exactly one argument, which may itself be a nested
dynamic variable expression such as `$header(Authorization)` or
`$arg(token)`, enabling patterns like `$redacted($header(Authorization))`.

Adds corresponding tests covering plain string redaction, nested header
and query arg wrapping, and the error case when no argument is provided.
2026-02-24 15:17:28 +08:00
yusing
77f2779114 fix(rules): expand nested function args
Allow $func(...) expressions inside function arguments by
extracting nested calls and expanding them before evaluation.
2026-02-24 15:15:42 +08:00
yusing
743eb03b27 fix(scripts): correct repo root path in update-wiki
The repoRootAbs was resolving to the script directory instead of the
repository root. Fixed by resolving two levels up from import.meta.dir.

Also optimized writeImplDocToMdx to skip writes when content is unchanged
and removed unused return value from syncImplDocs.
2026-02-24 15:10:03 +08:00
Yuzerion
d2d686b4d1 feat(rules): introduce block DSL, phase-based execution (#203)
* chore(deps): update submodule goutils

* docs(http): remove default client from README.md

* refactor(rules): introduce block DSL, phase-based execution, and flow validation

- add block syntax parser/scanner with nested @blocks and elif/else support
- restructure rule execution into explicit pre/post phases with phase flags
- classify commands by phase and termination behavior
- enforce flow semantics (default rule handling, dead-rule detection)
- expand HTTP flow coverage with block + YAML parity tests and benches
- refresh rules README/spec and update playground/docs integration
- Default rules act as fallback handlers that execute only when no matching non-default rule exists in the pre phase
- IfElseBlockCommand now returns early when a condition matches with a nil Do block, instead of falling through to else blocks
- Add nil check for auth handler to allow requests when no auth is configured

* fix(rules): buffer log output before writing to stdout/stderr

* refactor(api/rules): remove IsResponseRule field from ParsedRule and related logic

* docs(rules): update examples to use block syntax
2026-02-24 10:44:47 +08:00
yusing
169358659a refactor(middleware): improve response body modification gating
Refactor response body modification to only allow text-like content types
(JSON, YAML, XML, etc.) instead of all HTML responses.

Body modification is now
blocked for binary content and transfer/content encoded responses, while status
code and headers can still be modified.

This prevents issues with compressed or streaming responses while
maintaining the ability to modify text-based API responses.
2026-02-23 23:46:41 +08:00
yusing
0850ea3918 docs(http): remove default client from README.md 2026-02-23 14:51:30 +08:00
yusing
dd84d57f10 chore(deps): update submodule goutils 2026-02-23 14:51:03 +08:00
yusing
0aae9f07d1 chore(vscode): update YAML schema paths in settings example 2026-02-23 11:34:44 +08:00
yusing
ac1d8f3487 docs(readme): remove API endpoints section and clarify proxmox log streaming descriptions 2026-02-23 11:34:33 +08:00
yusing
6e8f5fb58d fix(server): fix race with closing listener first then shutdown 2026-02-23 11:28:09 +08:00
yusing
3001417a37 fix(health): only send recovery notification after down notification
Previously, up notifications were sent whenever a service recovered,
even if no down notification had been sent (e.g., when recovering
before the failure threshold was met). This could confuse users who
would receive "service is up" notifications without ever being
notified of a problem.

Now, recovery notifications are only sent when a prior down
notification exists, ensuring notification pairs are always complete.
2026-02-23 11:05:19 +08:00
yusing
730757e2c3 chore(ci): update GitHub Actions workflow to include versioned tags for CLI binary builds 2026-02-22 19:59:00 +08:00
yusing
be53b961b6 chore(env): add LOCAL_API_ADDR to env example 2026-02-22 19:55:26 +08:00
yusing
f6a82a3b7c docs(api): update swagger docs with field descriptions and operationId rename
- Add minimum: 0 validation to LogRetention properties (days, keep_size, last)
- Add "interned" descriptions to fstype, path, and name fields
- Rename operationId and x-id from "routes" to "list" for GET /route endpoint
2026-02-22 19:54:56 +08:00
yusing
4e5ded13fb fix(api/proxmox): add websocket validation to journalctl and tail endpoints
Add required websocket check at the beginning of both journalctl and tail endpoint handlers to ensure these endpoints only accept websocket connections.
2026-02-22 19:54:09 +08:00
yusing
2305eca90b feat(cli): add CLI application with automatic command generation from swagger
Add a new CLI application (`cmd/cli/`) that generates command-line interface commands from the API swagger specification. Includes:
- Main CLI entry point with command parsing and execution
- Code generator that reads swagger.json and generates typed command handlers
- Makefile targets (`gen-cli`, `build-cli`) for generating and building the CLI
- GitHub Actions workflow to build cross-platform CLI binaries (linux/amd64, linux/arm64)
2026-02-22 19:51:49 +08:00
yusing
4580543693 refactor(icons): improve favicon fetching with custom HTTP client and content-type validation
Replace the existing HTTP client with a custom-configured client that skips TLS verification for favicon fetching,
and add explicit Content-Type validation to ensure only valid image responses are accepted.

This fixes potential issues with SSL certificate validation and prevents processing of non-image responses.
2026-02-22 16:06:13 +08:00
yusing
bf54b51036 chore(http): remove stale default_client.go 2026-02-22 16:05:30 +08:00
yusing
8ba937ec4a refactor(middleware): replace sensitive fields with redacted types 2026-02-22 16:05:02 +08:00
yusing
0f78158c64 refactor: fix lint errors; improve error handling 2026-02-22 16:04:25 +08:00
yusing
3a7d1f8b18 refactor: modernize code with go fix 2026-02-21 13:03:21 +08:00
yusing
64ffe44a2d refactor(systeminfo): correct field usage regarding update to gopsutil submodule 2026-02-21 13:00:00 +08:00
yusing
dea37a437b chore: apply golangci-lint fmt 2026-02-21 12:56:51 +08:00
yusing
ee973f7997 chore(deps): upgrade dependencies and submodules 2026-02-21 12:53:34 +08:00
yusing
8756baf7fc fix(docs): remove application/json from /file/content API 2026-02-18 19:13:00 +08:00
yusing
a12bdeaf55 refactor(middleware): replace path prefix checks with function-based approach
Replace simple path prefix-based enforcement/bypass mechanism with a more
flexible function-based approach. This allows for more complex conditions
to determine when middleware should be enforced or bypassed.

- Add checkReqFunc and checkRespFunc types for flexible condition checking
- Replace enforcedPathPrefixes with separate enforce and bypass check functions
- Add static asset path detection for automatic bypassing
- Separate request and response check logic for better granularity
2026-02-18 19:12:07 +08:00
yusing
f7676b2dbd chore(go.mod): add backoff library for retrying operations 2026-02-18 18:17:29 +08:00
yusing
add7884a36 feat(icons): improve search ranking with priority-based matching
Restructure icon search to use a tiered ranking system:
- Exact matches get highest priority (rank 0)
- Prefix matches ranked by name length (rank 100+)
- Contains matches ranked by relevance (rank 500+)
- Fuzzy matches as fallback (rank 1000+)

Also refactors InitCache to use switch statements for clarity
and updates goutils submodule.
2026-02-18 18:13:34 +08:00
yusing
115fba4ff4 feat(route): allow empty listening port in port specification
Support the ":proxy" format where only the proxy port is specified.
When the listening port part is empty, it defaults to 0 instead of
returning a parse error.
2026-02-18 14:37:23 +08:00
yusing
bb757b2432 refactor: replace custom helper with stdlib strings.* 2026-02-18 14:26:29 +08:00
yusing
c2d8cca3b4 refactor(docs): migrate wiki generation to MDX format
- Convert markdown output to  fumadocs MDX
- Add api-md2mdx.ts for markdown to MDX transformation
- Remove sidebar auto-update functionality
- Change output directory from src/impl to content/docs/impl
- Update DOCS_DIR path in Makefile to local wiki directory
- Copy swagger.json directly instead of generating markdown
- Add argparse dependency for CLI argument parsing
2026-02-18 14:05:40 +08:00
yusing
20695c52e8 docs: unify header to import path for package docs 2026-02-18 03:25:32 +08:00
yusing
7baf0b6fe5 refactor(metrics): reorganize system info collection into separate functions
Split the monolithic AllSystemInfo handler into smaller, focused functions:
- Extract streamSystemInfo for channel consumption
- Add queueSystemInfo for safe non-blocking queue operations
- Create collectSystemInfoRound for parallel agent data collection
- Implement handleRoundResult for consistent round result processing
- Replace custom exponential backoff with cenkalti/backoff/v5 library

This improves code maintainability and separates concerns within the metrics API endpoint.
2026-02-16 20:19:18 +08:00
yusing
863f16862b chore: update screenshots 2026-02-16 10:48:41 +08:00
199 changed files with 8520 additions and 2110 deletions

View File

@@ -56,6 +56,10 @@ GODOXY_HTTP3_ENABLED=true
# API listening address
GODOXY_API_ADDR=127.0.0.1:8888
# Local API listening address (unauthenticated, optional)
# Useful for local development, debugging or automation
GODOXY_LOCAL_API_ADDR=
# Metrics
GODOXY_METRICS_DISABLE_CPU=false
GODOXY_METRICS_DISABLE_MEMORY=false

66
.github/workflows/cli-binary.yml vendored Executable file
View File

@@ -0,0 +1,66 @@
name: GoDoxy CLI Binary
on:
pull_request:
paths:
- "cmd/cli/**"
- "internal/api/v1/docs/swagger.json"
- "Makefile"
- ".github/workflows/cli-binary.yml"
push:
branches:
- main
paths:
- "cmd/cli/**"
- "internal/api/v1/docs/swagger.json"
- "Makefile"
- ".github/workflows/cli-binary.yml"
tags:
- v*
workflow_dispatch:
jobs:
build:
strategy:
matrix:
include:
- runner: ubuntu-latest
platform: linux/amd64
binary_name: godoxy-cli-linux-amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
binary_name: godoxy-cli-linux-arm64
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
submodules: "recursive"
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify dependencies
run: go mod verify
- name: Build CLI
run: |
make cli=1 NAME=${{ matrix.binary_name }} build
- name: Check binary
run: |
file bin/${{ matrix.binary_name }}
- name: Upload
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.binary_name }}
path: bin/${{ matrix.binary_name }}
- name: Upload to release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: bin/${{ matrix.binary_name }}

View File

@@ -1,4 +1,4 @@
name: Cherry-pick into Compat
name: Refresh Compat from Main Patch
on:
push:
@@ -8,7 +8,7 @@ on:
- ".github/workflows/merge-main-into-compat.yml"
jobs:
cherry-pick:
refresh-compat:
runs-on: ubuntu-latest
permissions:
contents: write
@@ -20,20 +20,9 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Cherry-pick commits from last tag
- name: Refresh compat with single patch commit
run: |
git fetch origin compat
git checkout compat
CURRENT_TAG=${{ github.ref_name }}
PREV_TAG=$(git describe --tags --abbrev=0 $CURRENT_TAG^ 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
echo "No previous tag found. Cherry-picking all commits up to $CURRENT_TAG"
git rev-list --reverse --no-merges $CURRENT_TAG | xargs -r git cherry-pick
else
echo "Cherry-picking commits from $PREV_TAG to $CURRENT_TAG"
git rev-list --reverse --no-merges $PREV_TAG..$CURRENT_TAG | xargs -r git cherry-pick
fi
./scripts/refresh-compat.sh
- name: Push compat
run: |
git push origin compat
git push origin compat --force

5
.gitignore vendored
View File

@@ -40,4 +40,7 @@ CLAUDE.md
!.trunk/configs
# minified files
**/*-min.*
**/*-min.*
# generated CLI commands
cmd/cli/generated_commands.go

View File

@@ -1,11 +1,11 @@
{
"yaml.schemas": {
"https://github.com/yusing/godoxy-webui/raw/refs/heads/main/types/godoxy/config.schema.json": [
"https://github.com/yusing/godoxy-webui/raw/refs/heads/main/src/types/godoxy/config.schema.json": [
"config.example.yml",
"config.yml"
],
"https://github.com/yusing/godoxy-webui/raw/refs/heads/main/types/godoxy/routes.schema.json": [
"https://github.com/yusing/godoxy-webui/raw/refs/heads/main/src/types/godoxy/routes.schema.json": [
"providers.example.yml"
]
}
}
}

32
AGENTS.md Normal file
View File

@@ -0,0 +1,32 @@
# AGENTS.md
## Development Commands
- Build: You should not run build command.
- Test: `go test -ldflags="-checklinkname=0" ...`
## Documentation
Update package level `README.md` if exists after making significant changes.
## Go Guidelines
1. Use builtin `min` and `max` functions instead of creating custom ones
2. Prefer `for i := range 10` over `for i := 0; i < 10; i++`
3. Beware of variable shadowing when making edits
4. Use `internal/task/task.go` for lifetime management:
- `task.RootTask()` for background operations
- `parent.Subtask()` for nested tasks
- `OnFinished()` and `OnCancel()` callbacks for cleanup
5. Use `gperr "goutils/errs"` to build pretty nested errors:
- `gperr.Multiline()` for multiple operation attempts
- `gperr.NewBuilder()` to collect errors
- `gperr.NewGroup() + group.Go()` to collect errors of multiple concurrent operations
- `gperr.PrependSubject()` to prepend subject to errors
6. Use `github.com/puzpuzpuz/xsync/v4` for lock-free thread safe maps
7. Use `goutils/synk` to retrieve and put byte buffer
## Testing
- Run scoped tests instead of `./...`
- Use `testify`, no manual assertions.

View File

@@ -6,7 +6,7 @@ export GOOS = linux
REPO_URL ?= https://github.com/yusing/godoxy
WEBUI_DIR ?= ../godoxy-webui
WEBUI_DIR ?= $(shell pwd)/../godoxy-webui
DOCS_DIR ?= ${WEBUI_DIR}/wiki
ifneq ($(BRANCH), compat)
@@ -17,15 +17,22 @@ endif
LDFLAGS = -X github.com/yusing/goutils/version.version=${VERSION} -checklinkname=0
PACKAGE ?= ./cmd
ifeq ($(agent), 1)
NAME = godoxy-agent
PWD = ${shell pwd}/agent
else ifeq ($(socket-proxy), 1)
NAME = godoxy-socket-proxy
PWD = ${shell pwd}/socket-proxy
else ifeq ($(cli), 1)
NAME = godoxy-cli
PWD = ${shell pwd}/cmd/cli
PACKAGE = .
else
NAME = godoxy
PWD = ${shell pwd}
godoxy = 1
endif
ifeq ($(trace), 1)
@@ -75,7 +82,11 @@ endif
# CAP_NET_BIND_SERVICE: permission for binding to :80 and :443
POST_BUILD = $(SETCAP_CMD) CAP_NET_BIND_SERVICE=+ep ${BIN_PATH};
POST_BUILD = echo;
ifeq ($(godoxy), 1)
POST_BUILD += $(SETCAP_CMD) CAP_NET_BIND_SERVICE=+ep ${BIN_PATH};
endif
ifeq ($(docker), 1)
POST_BUILD += mkdir -p /app && mv ${BIN_PATH} /app/run;
endif
@@ -132,13 +143,18 @@ minify-js:
done \
fi
build: minify-js
build:
@if [ "${godoxy}" = "1" ]; then \
make minify-js; \
elif [ "${cli}" = "1" ]; then \
make gen-cli; \
fi
mkdir -p $(shell dirname ${BIN_PATH})
go build -C ${PWD} ${BUILD_FLAGS} -o ${BIN_PATH} ./cmd
go build -C ${PWD} ${BUILD_FLAGS} -o ${BIN_PATH} ${PACKAGE}
${POST_BUILD}
run: minify-js
cd ${PWD} && [ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ./cmd
cd ${PWD} && [ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ${PACKAGE}
dev:
docker compose -f dev.compose.yml $(args)
@@ -178,16 +194,17 @@ gen-swagger:
python3 scripts/fix-swagger-json.py
# we don't need this
rm internal/api/v1/docs/docs.go
gen-swagger-markdown: gen-swagger
# brew tap go-swagger/go-swagger && brew install go-swagger
swagger generate markdown -f internal/api/v1/docs/swagger.yaml --skip-validation --output ${DOCS_DIR}/src/API.md
cp internal/api/v1/docs/swagger.json ${DOCS_DIR}/public/api.json
gen-api-types: gen-swagger
# --disable-throw-on-error
bunx --bun swagger-typescript-api generate --sort-types --generate-union-enums --axios --add-readonly --route-types \
--responses -o ${WEBUI_DIR}/src/lib -n api.ts -p internal/api/v1/docs/swagger.json
.PHONY: update-wiki
.PHONY: gen-cli build-cli update-wiki
gen-cli:
cd cmd/cli && go run ./gen
update-wiki:
DOCS_DIR=${DOCS_DIR} REPO_URL=${REPO_URL} bun --bun scripts/update-wiki/main.ts

View File

@@ -36,7 +36,6 @@ Have questions? Ask [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e4
- [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)
@@ -167,23 +166,8 @@ routes:
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
```
- **Node Logs**: Stream real-time journalctl or log files output from nodes
- **LXC Logs**: Stream real-time journalctl or log files output from containers
## Update / Uninstall system agent

View File

@@ -37,7 +37,6 @@
- [Proxmox 整合](#proxmox-整合)
- [自動路由綁定](#自動路由綁定)
- [WebUI 管理](#webui-管理)
- [API 端點](#api-端點)
- [更新 / 卸載系統代理 (System Agent)](#更新--卸載系統代理-system-agent)
- [截圖](#截圖)
- [閒置休眠](#閒置休眠)
@@ -183,23 +182,8 @@ routes:
您可以從 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
```
- **節點日誌**:串流節點的即時 journalctl 或日誌檔案輸出
- **LXC 日誌**:串流容器的即時 journalctl 或日誌檔案輸出
## 更新 / 卸載系統代理 (System Agent)

View File

@@ -27,7 +27,7 @@ require (
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.25.3
github.com/yusing/godoxy v0.26.0
github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000
github.com/yusing/goutils v0.7.0
)
@@ -46,7 +46,7 @@ require (
github.com/docker/cli v29.2.1+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
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
@@ -63,7 +63,7 @@ require (
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@@ -91,8 +91,8 @@ require (
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/yusing/ds v0.4.1 // indirect
github.com/yusing/gointernals v0.2.0 // indirect
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260211095624-f5a276d5c58b // indirect
github.com/yusing/goutils/http/websocket v0.0.0-20260211095624-f5a276d5c58b // indirect
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260223150038-3be815cb6e3b // indirect
github.com/yusing/goutils/http/websocket v0.0.0-20260223150038-3be815cb6e3b // 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.65.0 // indirect

View File

@@ -43,8 +43,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
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=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -55,8 +55,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-acme/lego/v4 v4.31.0 h1:gd4oUYdfs83PR1/SflkNdit9xY1iul2I4EystnU8NXM=
github.com/go-acme/lego/v4 v4.31.0/go.mod h1:m6zcfX/zcbMYDa8s6AnCMnoORWNP8Epnei+6NBCTUGs=
github.com/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -93,8 +93,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotify/server/v2 v2.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA=
github.com/gotify/server/v2 v2.8.0/go.mod h1:6ci5adxcE2hf1v+2oowKiQmixOxXV8vU+CRLKP6sqZA=
github.com/gotify/server/v2 v2.9.0 h1:2zRCl28wkq0oc6YNbyJS2n0dDOOVvOS3Oez5AG2ij54=
github.com/gotify/server/v2 v2.9.0/go.mod h1:249wwlUqHTr0QsiKARGtFVqds0pNLIMjYLinHyMACdQ=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
@@ -111,10 +111,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
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/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/luthermonson/go-proxmox v0.4.0 h1:LKXpG9d64zTaQF79wV0kfOnnSwIcdG39m7sc4ga+XZs=
github.com/luthermonson/go-proxmox v0.4.0/go.mod h1:U6dAkJ+iiwaeb1g/LMWpWuWN4nmvWeXhmoMuYJMumS4=
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=

View File

@@ -1,4 +1,4 @@
# Agent Package
# agent/pkg/agent
The `agent` package provides the client-side implementation for interacting with GoDoxy agents. It handles agent configuration, secure communication via TLS, and provides utilities for agent deployment and management.

View File

@@ -1,4 +1,4 @@
# Stream proxy protocol
# agent/pkg/agent/stream
This package implements a small header-based handshake that allows an authenticated client to request forwarding to a `(host, port)` destination. It supports both TCP-over-TLS and UDP-over-DTLS transports.

View File

@@ -21,8 +21,10 @@ const (
var version = [versionSize]byte{'0', '.', '1', '.', '0', 0, 0, 0}
var ErrInvalidHeader = errors.New("invalid header")
var ErrCloseImmediately = errors.New("close immediately")
var (
ErrInvalidHeader = errors.New("invalid header")
ErrCloseImmediately = errors.New("close immediately")
)
type FlagType uint8

View File

@@ -45,9 +45,10 @@ func TestTLSALPNMux_HTTPAndStreamShareOnePort(t *testing.T) {
defer func() { _ = tlsLn.Close() }()
// HTTP server
httpSrv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
}),
httpSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
}),
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){
stream.StreamALPN: func(_ *http.Server, conn *tls.Conn, _ http.Handler) {
streamSrv.ServeConn(conn)

View File

@@ -102,7 +102,6 @@ func TestUDPServer_RejectInvalidClient(t *testing.T) {
srv := startUDPServer(t, certs)
// Try to connect with a client cert from a different CA
_, err = stream.NewUDPClient(srv.Addr.String(), dstAddr, certs.CaCert, invalidClientCert)
require.Error(t, err, "expected error when connecting with client cert from different CA")

View File

@@ -2,13 +2,14 @@ package main
import (
"log"
"net/http"
"math/rand/v2"
"net/http"
)
var printables = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var random = make([]byte, 4096)
var (
printables = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
random = make([]byte, 4096)
)
func init() {
for i := range random {

730
cmd/cli/cli.go Executable file
View File

@@ -0,0 +1,730 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/yusing/goutils/env"
)
type config struct {
Addr string
}
type stringSliceFlag struct {
set bool
v []string
}
func (s *stringSliceFlag) String() string {
return strings.Join(s.v, ",")
}
func (s *stringSliceFlag) Set(value string) error {
s.set = true
if value == "" {
s.v = nil
return nil
}
s.v = strings.Split(value, ",")
return nil
}
func run(args []string) error {
cfg, rest, err := parseGlobal(args)
if err != nil {
return err
}
if len(rest) == 0 {
printHelp()
return nil
}
if rest[0] == "help" {
printHelp()
return nil
}
ep, matchedLen := findEndpoint(rest)
if ep == nil {
ep, matchedLen = findEndpointAlias(rest)
}
if ep == nil {
return unknownCommandError(rest)
}
cmdArgs := rest[matchedLen:]
return executeEndpoint(cfg.Addr, *ep, cmdArgs)
}
func parseGlobal(args []string) (config, []string, error) {
var cfg config
fs := flag.NewFlagSet("godoxy", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&cfg.Addr, "addr", "", "API address, e.g. 127.0.0.1:8888 or http://127.0.0.1:8888")
if err := fs.Parse(args); err != nil {
return cfg, nil, err
}
return cfg, fs.Args(), nil
}
func resolveBaseURL(addrFlag string) (string, error) {
if addrFlag != "" {
return normalizeURL(addrFlag), nil
}
_, _, _, fullURL := env.GetAddrEnv("LOCAL_API_ADDR", "", "http")
if fullURL == "" {
return "", errors.New("missing LOCAL_API_ADDR (or GODOXY_LOCAL_API_ADDR). set env var or pass --addr")
}
return normalizeURL(fullURL), nil
}
func normalizeURL(addr string) string {
a := strings.TrimSpace(addr)
if strings.Contains(a, "://") {
return strings.TrimRight(a, "/")
}
return "http://" + strings.TrimRight(a, "/")
}
func findEndpoint(args []string) (*Endpoint, int) {
var best *Endpoint
bestLen := -1
for i := range generatedEndpoints {
ep := &generatedEndpoints[i]
if len(ep.CommandPath) > len(args) {
continue
}
ok := true
for j, tok := range ep.CommandPath {
if args[j] != tok {
ok = false
break
}
}
if ok && len(ep.CommandPath) > bestLen {
best = ep
bestLen = len(ep.CommandPath)
}
}
return best, bestLen
}
func executeEndpoint(addrFlag string, ep Endpoint, args []string) error {
fs := flag.NewFlagSet(strings.Join(ep.CommandPath, "-"), flag.ContinueOnError)
fs.SetOutput(io.Discard)
useWS := false
if ep.IsWebSocket {
fs.BoolVar(&useWS, "ws", false, "use websocket")
}
typedValues := make(map[string]any, len(ep.Params))
isSet := make(map[string]bool, len(ep.Params))
for _, p := range ep.Params {
switch p.Type {
case "integer":
v := new(int)
fs.IntVar(v, p.FlagName, 0, p.Description)
typedValues[p.FlagName] = v
case "number":
v := new(float64)
fs.Float64Var(v, p.FlagName, 0, p.Description)
typedValues[p.FlagName] = v
case "boolean":
v := new(bool)
fs.BoolVar(v, p.FlagName, false, p.Description)
typedValues[p.FlagName] = v
case "array":
v := &stringSliceFlag{}
fs.Var(v, p.FlagName, p.Description+" (comma-separated)")
typedValues[p.FlagName] = v
default:
v := new(string)
fs.StringVar(v, p.FlagName, "", p.Description)
typedValues[p.FlagName] = v
}
}
if err := fs.Parse(args); err != nil {
return fmt.Errorf("%w\n\n%s", err, formatEndpointHelp(ep))
}
if len(fs.Args()) > 0 {
return fmt.Errorf("unexpected args: %s\n\n%s", strings.Join(fs.Args(), " "), formatEndpointHelp(ep))
}
fs.Visit(func(f *flag.Flag) {
isSet[f.Name] = true
})
for _, p := range ep.Params {
if !p.Required {
continue
}
if !isSet[p.FlagName] {
return fmt.Errorf("missing required flag --%s\n\n%s", p.FlagName, formatEndpointHelp(ep))
}
}
baseURL, err := resolveBaseURL(addrFlag)
if err != nil {
return err
}
reqURL, body, err := buildRequest(ep, baseURL, typedValues, isSet)
if err != nil {
return err
}
if useWS {
if !ep.IsWebSocket {
return errors.New("--ws is only supported for websocket endpoints")
}
return execWebsocket(ep, reqURL)
}
return execHTTP(ep, reqURL, body)
}
func buildRequest(ep Endpoint, baseURL string, typedValues map[string]any, isSet map[string]bool) (string, []byte, error) {
path := ep.Path
for _, p := range ep.Params {
if p.In != "path" {
continue
}
raw, err := paramValueString(p, typedValues[p.FlagName], isSet[p.FlagName])
if err != nil {
return "", nil, err
}
if raw == "" {
continue
}
esc := url.PathEscape(raw)
path = strings.ReplaceAll(path, "{"+p.Name+"}", esc)
path = strings.ReplaceAll(path, ":"+p.Name, esc)
}
u, err := url.Parse(baseURL)
if err != nil {
return "", nil, fmt.Errorf("invalid base url: %w", err)
}
u.Path = strings.TrimRight(u.Path, "/") + path
q := u.Query()
for _, p := range ep.Params {
if p.In != "query" || !isSet[p.FlagName] {
continue
}
val, err := paramQueryValues(p, typedValues[p.FlagName])
if err != nil {
return "", nil, err
}
for _, v := range val {
q.Add(p.Name, v)
}
}
u.RawQuery = q.Encode()
bodyMap := map[string]any{}
rawBody := ""
for _, p := range ep.Params {
if p.In != "body" || !isSet[p.FlagName] {
continue
}
if p.Name == "file" {
s, err := paramValueString(p, typedValues[p.FlagName], true)
if err != nil {
return "", nil, err
}
rawBody = s
continue
}
v, err := paramBodyValue(p, typedValues[p.FlagName])
if err != nil {
return "", nil, err
}
bodyMap[p.Name] = v
}
if rawBody != "" {
return u.String(), []byte(rawBody), nil
}
if len(bodyMap) == 0 {
return u.String(), nil, nil
}
data, err := json.Marshal(bodyMap)
if err != nil {
return "", nil, fmt.Errorf("marshal body: %w", err)
}
return u.String(), data, nil
}
func paramValueString(p Param, raw any, wasSet bool) (string, error) {
if !wasSet {
return "", nil
}
switch v := raw.(type) {
case *string:
return *v, nil
case *int:
return strconv.Itoa(*v), nil
case *float64:
return strconv.FormatFloat(*v, 'f', -1, 64), nil
case *bool:
if *v {
return "true", nil
}
return "false", nil
case *stringSliceFlag:
return strings.Join(v.v, ","), nil
default:
return "", fmt.Errorf("unsupported flag value for %s", p.FlagName)
}
}
func paramQueryValues(p Param, raw any) ([]string, error) {
switch v := raw.(type) {
case *string:
return []string{*v}, nil
case *int:
return []string{strconv.Itoa(*v)}, nil
case *float64:
return []string{strconv.FormatFloat(*v, 'f', -1, 64)}, nil
case *bool:
if *v {
return []string{"true"}, nil
}
return []string{"false"}, nil
case *stringSliceFlag:
if len(v.v) == 0 {
return nil, nil
}
return v.v, nil
default:
return nil, fmt.Errorf("unsupported query flag type for %s", p.FlagName)
}
}
func paramBodyValue(p Param, raw any) (any, error) {
switch v := raw.(type) {
case *string:
if p.Type == "object" || p.Type == "array" {
var decoded any
if err := json.Unmarshal([]byte(*v), &decoded); err != nil {
return nil, fmt.Errorf("invalid JSON for --%s: %w", p.FlagName, err)
}
return decoded, nil
}
return *v, nil
case *int:
return *v, nil
case *float64:
return *v, nil
case *bool:
return *v, nil
case *stringSliceFlag:
return v.v, nil
default:
return nil, fmt.Errorf("unsupported body flag type for %s", p.FlagName)
}
}
func execHTTP(ep Endpoint, reqURL string, body []byte) error {
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(ep.Method, reqURL, r)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if len(payload) == 0 {
return fmt.Errorf("%s %s failed: %s", ep.Method, ep.Path, resp.Status)
}
return fmt.Errorf("%s %s failed: %s: %s", ep.Method, ep.Path, resp.Status, strings.TrimSpace(string(payload)))
}
printJSON(payload)
return nil
}
func execWebsocket(ep Endpoint, reqURL string) error {
wsURL := strings.Replace(reqURL, "http://", "ws://", 1)
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
if strings.ToUpper(ep.Method) != http.MethodGet {
return fmt.Errorf("--ws requires GET endpoint, got %s", ep.Method)
}
c, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return err
}
defer c.Close()
stopPing := make(chan struct{})
defer close(stopPing)
go func() {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-stopPing:
return
case <-ticker.C:
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil {
return
}
}
}
}()
for {
_, msg, err := c.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) || strings.Contains(err.Error(), "close") {
return nil
}
return err
}
if string(msg) == "pong" {
continue
}
fmt.Println(string(msg))
}
}
func printJSON(payload []byte) {
if len(payload) == 0 {
fmt.Println("null")
return
}
var v any
if err := json.Unmarshal(payload, &v); err != nil {
fmt.Println(strings.TrimSpace(string(payload)))
return
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
func printHelp() {
fmt.Println("godoxy [--addr ADDR] <command>")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" godoxy version")
fmt.Println(" godoxy route list")
fmt.Println(" godoxy route route --which whoami")
fmt.Println()
printGroupedCommands()
}
func printGroupedCommands() {
grouped := map[string][]Endpoint{}
groupOrder := make([]string, 0)
seen := map[string]bool{}
for _, ep := range generatedEndpoints {
group := "root"
if len(ep.CommandPath) > 1 {
group = ep.CommandPath[0]
}
grouped[group] = append(grouped[group], ep)
if !seen[group] {
seen[group] = true
groupOrder = append(groupOrder, group)
}
}
sort.Strings(groupOrder)
for _, group := range groupOrder {
fmt.Printf("Commands (%s):\n", group)
sort.Slice(grouped[group], func(i, j int) bool {
li := strings.Join(grouped[group][i].CommandPath, " ")
lj := strings.Join(grouped[group][j].CommandPath, " ")
return li < lj
})
maxCmdWidth := 0
for _, ep := range grouped[group] {
cmd := strings.Join(ep.CommandPath, " ")
if len(cmd) > maxCmdWidth {
maxCmdWidth = len(cmd)
}
}
for _, ep := range grouped[group] {
cmd := strings.Join(ep.CommandPath, " ")
fmt.Printf(" %-*s %s\n", maxCmdWidth, cmd, ep.Summary)
}
fmt.Println()
}
}
func unknownCommandError(rest []string) error {
cmd := strings.Join(rest, " ")
var b strings.Builder
b.WriteString("unknown command: ")
b.WriteString(cmd)
if len(rest) > 0 && hasGroup(rest[0]) {
if len(rest) > 1 {
if hint := nearestForGroup(rest[0], rest[1]); hint != "" {
b.WriteString("\nDo you mean ")
b.WriteString(hint)
b.WriteString("?")
}
}
b.WriteString("\n\n")
b.WriteString(formatGroupHelp(rest[0]))
return errors.New(b.String())
}
if hint := nearestCommand(cmd); hint != "" {
b.WriteString("\nDo you mean ")
b.WriteString(hint)
b.WriteString("?")
}
b.WriteString("\n\n")
b.WriteString("Run `godoxy help` for available commands.")
return errors.New(b.String())
}
func findEndpointAlias(args []string) (*Endpoint, int) {
var best *Endpoint
bestLen := -1
for i := range generatedEndpoints {
alias := aliasCommandPath(generatedEndpoints[i])
if len(alias) == 0 || len(alias) > len(args) {
continue
}
ok := true
for j, tok := range alias {
if args[j] != tok {
ok = false
break
}
}
if ok && len(alias) > bestLen {
best = &generatedEndpoints[i]
bestLen = len(alias)
}
}
return best, bestLen
}
func aliasCommandPath(ep Endpoint) []string {
rawPath := strings.TrimPrefix(ep.Path, "/api/v1/")
rawPath = strings.Trim(rawPath, "/")
if rawPath == "" {
return nil
}
parts := strings.Split(rawPath, "/")
if len(parts) == 1 {
if isPathParam(parts[0]) {
return nil
}
return []string{toKebabToken(parts[0])}
}
if isPathParam(parts[0]) || isPathParam(parts[1]) {
return nil
}
return []string{toKebabToken(parts[0]), toKebabToken(parts[1])}
}
func isPathParam(s string) bool {
return strings.HasPrefix(s, "{") || strings.HasPrefix(s, ":")
}
func toKebabToken(s string) string {
s = strings.ReplaceAll(s, "_", "-")
return strings.ToLower(strings.Trim(s, "-"))
}
func hasGroup(group string) bool {
for _, ep := range generatedEndpoints {
if len(ep.CommandPath) > 1 && ep.CommandPath[0] == group {
return true
}
}
return false
}
func nearestCommand(input string) string {
commands := make([]string, 0, len(generatedEndpoints))
for _, ep := range generatedEndpoints {
commands = append(commands, strings.Join(ep.CommandPath, " "))
}
return nearestByDistance(input, commands)
}
func nearestForGroup(group, input string) string {
choiceSet := map[string]struct{}{}
for _, ep := range generatedEndpoints {
if len(ep.CommandPath) < 2 || ep.CommandPath[0] != group {
continue
}
choiceSet[ep.CommandPath[1]] = struct{}{}
alias := aliasCommandPath(ep)
if len(alias) == 2 && alias[0] == group {
choiceSet[alias[1]] = struct{}{}
}
}
choices := make([]string, 0, len(choiceSet))
for choice := range choiceSet {
choices = append(choices, choice)
}
if len(choices) == 0 {
return ""
}
return group + " " + nearestByDistance(input, choices)
}
func formatGroupHelp(group string) string {
commands := make([]Endpoint, 0)
for _, ep := range generatedEndpoints {
if len(ep.CommandPath) > 1 && ep.CommandPath[0] == group {
commands = append(commands, ep)
}
}
sort.Slice(commands, func(i, j int) bool {
return strings.Join(commands[i].CommandPath, " ") < strings.Join(commands[j].CommandPath, " ")
})
maxWidth := 0
for _, ep := range commands {
cmd := strings.Join(ep.CommandPath, " ")
if len(cmd) > maxWidth {
maxWidth = len(cmd)
}
}
var b strings.Builder
fmt.Fprintf(&b, "Available subcommands for %s:\n", group)
for _, ep := range commands {
cmd := strings.Join(ep.CommandPath, " ")
fmt.Fprintf(&b, " %-*s %s\n", maxWidth, cmd, ep.Summary)
}
return strings.TrimRight(b.String(), "\n")
}
func formatEndpointHelp(ep Endpoint) string {
cmd := "godoxy " + strings.Join(ep.CommandPath, " ")
var b strings.Builder
fmt.Fprintf(&b, "Usage: %s [flags]\n", cmd)
if ep.Summary != "" {
fmt.Fprintf(&b, "Summary: %s\n", ep.Summary)
}
if alias := aliasCommandPath(ep); len(alias) > 0 && strings.Join(alias, " ") != strings.Join(ep.CommandPath, " ") {
fmt.Fprintf(&b, "Alias: godoxy %s\n", strings.Join(alias, " "))
}
params := make([]Param, 0, len(ep.Params))
params = append(params, ep.Params...)
if ep.IsWebSocket {
params = append(params, Param{
FlagName: "ws",
Type: "boolean",
Description: "use websocket",
Required: false,
})
}
if len(params) == 0 {
return strings.TrimRight(b.String(), "\n")
}
b.WriteString("Flags:\n")
maxWidth := 0
flagNames := make([]string, 0, len(params))
for _, p := range params {
name := "--" + p.FlagName
if p.Required {
name += " (required)"
}
flagNames = append(flagNames, name)
if len(name) > maxWidth {
maxWidth = len(name)
}
}
for i, p := range params {
desc := p.Description
if desc == "" {
desc = p.In + " " + p.Type
}
fmt.Fprintf(&b, " %-*s %s\n", maxWidth, flagNames[i], desc)
}
return strings.TrimRight(b.String(), "\n")
}
func nearestByDistance(input string, choices []string) string {
if len(choices) == 0 {
return ""
}
nearest := choices[0]
minDistance := levenshteinDistance(input, nearest)
for _, choice := range choices[1:] {
d := levenshteinDistance(input, choice)
if d < minDistance {
minDistance = d
nearest = choice
}
}
return nearest
}
//nolint:intrange
func levenshteinDistance(a, b string) int {
if a == b {
return 0
}
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
v0 := make([]int, len(b)+1)
v1 := make([]int, len(b)+1)
for i := 0; i <= len(b); i++ {
v0[i] = i
}
for i := 0; i < len(a); i++ {
v1[0] = i + 1
for j := 0; j < len(b); j++ {
cost := 0
if a[i] != b[j] {
cost = 1
}
v1[j+1] = min3(v1[j]+1, v0[j+1]+1, v0[j]+cost)
}
for j := 0; j <= len(b); j++ {
v0[j] = v1[j]
}
}
return v1[len(b)]
}
func min3(a, b, c int) int {
if a < b && a < c {
return a
}
if b < a && b < c {
return b
}
return c
}

366
cmd/cli/gen/main.go Executable file
View File

@@ -0,0 +1,366 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"go/format"
"os"
"path/filepath"
"sort"
"strings"
"unicode"
)
type swaggerSpec struct {
BasePath string `json:"basePath"`
Paths map[string]map[string]operation `json:"paths"`
Definitions map[string]definition `json:"definitions"`
}
type operation struct {
OperationID string `json:"operationId"`
Summary string `json:"summary"`
Tags []string `json:"tags"`
Parameters []parameter `json:"parameters"`
}
type parameter struct {
Name string `json:"name"`
In string `json:"in"`
Required bool `json:"required"`
Type string `json:"type"`
Description string `json:"description"`
Schema *schemaRef `json:"schema"`
}
type schemaRef struct {
Ref string `json:"$ref"`
}
type definition struct {
Type string `json:"type"`
Required []string `json:"required"`
Properties map[string]definition `json:"properties"`
Items *definition `json:"items"`
}
type endpoint struct {
CommandPath []string
Method string
Path string
Summary string
IsWebSocket bool
Params []param
}
type param struct {
FlagName string
Name string
In string
Type string
Required bool
Description string
}
func main() {
root := filepath.Join("..", "..")
inPath := filepath.Join(root, "internal", "api", "v1", "docs", "swagger.json")
outPath := "generated_commands.go"
raw, err := os.ReadFile(inPath)
must(err)
var spec swaggerSpec
must(json.Unmarshal(raw, &spec))
eps := buildEndpoints(spec)
must(writeGenerated(outPath, eps))
}
func buildEndpoints(spec swaggerSpec) []endpoint {
byCommand := map[string]endpoint{}
pathKeys := make([]string, 0, len(spec.Paths))
for p := range spec.Paths {
pathKeys = append(pathKeys, p)
}
sort.Strings(pathKeys)
for _, p := range pathKeys {
methodMap := spec.Paths[p]
methods := make([]string, 0, len(methodMap))
for m := range methodMap {
methods = append(methods, strings.ToUpper(m))
}
sort.Strings(methods)
for _, method := range methods {
op := methodMap[strings.ToLower(method)]
if op.OperationID == "" {
continue
}
ep := endpoint{
CommandPath: commandPathFromOp(p, op.OperationID),
Method: method,
Path: ensureSlash(spec.BasePath) + normalizePath(p),
Summary: op.Summary,
IsWebSocket: hasTag(op.Tags, "websocket"),
Params: collectParams(spec, op),
}
key := strings.Join(ep.CommandPath, " ")
if existing, ok := byCommand[key]; ok {
if betterEndpoint(ep, existing) {
byCommand[key] = ep
}
continue
}
byCommand[key] = ep
}
}
out := make([]endpoint, 0, len(byCommand))
for _, ep := range byCommand {
out = append(out, ep)
}
sort.Slice(out, func(i, j int) bool {
ai := strings.Join(out[i].CommandPath, " ")
aj := strings.Join(out[j].CommandPath, " ")
return ai < aj
})
return out
}
func commandPathFromOp(path, opID string) []string {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 0 {
return []string{toKebab(opID)}
}
if len(parts) == 1 {
return []string{toKebab(parts[0])}
}
group := toKebab(parts[0])
name := toKebab(opID)
if name == group {
name = "get"
}
if group == "v1" {
return []string{name}
}
return []string{group, name}
}
func collectParams(spec swaggerSpec, op operation) []param {
params := make([]param, 0)
for _, p := range op.Parameters {
switch p.In {
case "body":
if p.Schema != nil && p.Schema.Ref != "" {
defName := strings.TrimPrefix(p.Schema.Ref, "#/definitions/")
params = append(params, bodyParamsFromDef(spec.Definitions[defName])...)
continue
}
params = append(params, param{
FlagName: toKebab(p.Name),
Name: p.Name,
In: "body",
Type: defaultType(p.Type),
Required: p.Required,
Description: p.Description,
})
default:
params = append(params, param{
FlagName: toKebab(p.Name),
Name: p.Name,
In: p.In,
Type: defaultType(p.Type),
Required: p.Required,
Description: p.Description,
})
}
}
// Deduplicate by flag name, prefer required entries.
byFlag := map[string]param{}
for _, p := range params {
if cur, ok := byFlag[p.FlagName]; ok {
if !cur.Required && p.Required {
byFlag[p.FlagName] = p
}
continue
}
byFlag[p.FlagName] = p
}
out := make([]param, 0, len(byFlag))
for _, p := range byFlag {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool {
if out[i].In != out[j].In {
return out[i].In < out[j].In
}
return out[i].FlagName < out[j].FlagName
})
return out
}
func bodyParamsFromDef(def definition) []param {
if def.Type != "object" {
return nil
}
requiredSet := map[string]struct{}{}
for _, name := range def.Required {
requiredSet[name] = struct{}{}
}
keys := make([]string, 0, len(def.Properties))
for k := range def.Properties {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]param, 0, len(keys))
for _, k := range keys {
prop := def.Properties[k]
_, required := requiredSet[k]
t := defaultType(prop.Type)
if prop.Type == "array" {
t = "array"
}
if prop.Type == "object" {
t = "object"
}
out = append(out, param{
FlagName: toKebab(k),
Name: k,
In: "body",
Type: t,
Required: required,
})
}
return out
}
func betterEndpoint(a, b endpoint) bool {
// Prefer GET, then fewer path params, then shorter path.
if a.Method == "GET" && b.Method != "GET" {
return true
}
if a.Method != "GET" && b.Method == "GET" {
return false
}
ac := countPathParams(a.Path)
bc := countPathParams(b.Path)
if ac != bc {
return ac < bc
}
return len(a.Path) < len(b.Path)
}
func countPathParams(path string) int {
count := 0
for _, seg := range strings.Split(path, "/") {
if strings.HasPrefix(seg, "{") || strings.HasPrefix(seg, ":") {
count++
}
}
return count
}
func normalizePath(p string) string {
parts := strings.Split(p, "/")
for i, part := range parts {
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
name := strings.TrimSuffix(strings.TrimPrefix(part, "{"), "}")
parts[i] = "{" + name + "}"
}
}
return strings.Join(parts, "/")
}
func hasTag(tags []string, want string) bool {
for _, t := range tags {
if strings.EqualFold(t, want) {
return true
}
}
return false
}
func writeGenerated(outPath string, eps []endpoint) error {
var b bytes.Buffer
b.WriteString("// Code generated by cmd/cli/gen. DO NOT EDIT.\n")
b.WriteString("package main\n\n")
b.WriteString("var generatedEndpoints = []Endpoint{\n")
for _, ep := range eps {
b.WriteString("\t{\n")
fmt.Fprintf(&b, "\t\tCommandPath: %#v,\n", ep.CommandPath)
fmt.Fprintf(&b, "\t\tMethod: %q,\n", ep.Method)
fmt.Fprintf(&b, "\t\tPath: %q,\n", ep.Path)
fmt.Fprintf(&b, "\t\tSummary: %q,\n", ep.Summary)
fmt.Fprintf(&b, "\t\tIsWebSocket: %t,\n", ep.IsWebSocket)
b.WriteString("\t\tParams: []Param{\n")
for _, p := range ep.Params {
b.WriteString("\t\t\t{\n")
fmt.Fprintf(&b, "\t\t\t\tFlagName: %q,\n", p.FlagName)
fmt.Fprintf(&b, "\t\t\t\tName: %q,\n", p.Name)
fmt.Fprintf(&b, "\t\t\t\tIn: %q,\n", p.In)
fmt.Fprintf(&b, "\t\t\t\tType: %q,\n", p.Type)
fmt.Fprintf(&b, "\t\t\t\tRequired: %t,\n", p.Required)
fmt.Fprintf(&b, "\t\t\t\tDescription: %q,\n", p.Description)
b.WriteString("\t\t\t},\n")
}
b.WriteString("\t\t},\n")
b.WriteString("\t},\n")
}
b.WriteString("}\n")
formatted, err := format.Source(b.Bytes())
if err != nil {
return fmt.Errorf("format generated source: %w", err)
}
return os.WriteFile(outPath, formatted, 0o644)
}
func ensureSlash(s string) string {
if strings.HasPrefix(s, "/") {
return s
}
return "/" + s
}
func defaultType(t string) string {
switch t {
case "integer", "number", "boolean", "array", "object", "string":
return t
default:
return "string"
}
}
func toKebab(s string) string {
if s == "" {
return s
}
s = strings.ReplaceAll(s, "_", "-")
s = strings.ReplaceAll(s, ".", "-")
var out []rune
for i, r := range s {
if unicode.IsUpper(r) {
if i > 0 && out[len(out)-1] != '-' {
out = append(out, '-')
}
out = append(out, unicode.ToLower(r))
continue
}
out = append(out, unicode.ToLower(r))
}
res := strings.Trim(string(out), "-")
for strings.Contains(res, "--") {
res = strings.ReplaceAll(res, "--", "-")
}
return res
}
func must(err error) {
if err != nil {
panic(err)
}
}

10
cmd/cli/go.mod Normal file
View File

@@ -0,0 +1,10 @@
module github.com/yusing/godoxy/cli
go 1.26.0
require (
github.com/gorilla/websocket v1.5.3
github.com/yusing/goutils v0.7.0
)
replace github.com/yusing/goutils => ../../goutils

10
cmd/cli/go.sum Executable file
View File

@@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

13
cmd/cli/main.go Normal file
View File

@@ -0,0 +1,13 @@
package main
import (
"fmt"
"os"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}

19
cmd/cli/types.go Executable file
View File

@@ -0,0 +1,19 @@
package main
type Param struct {
FlagName string
Name string
In string
Type string
Required bool
Description string
}
type Endpoint struct {
CommandPath []string
Method string
Path string
Summary string
IsWebSocket bool
Params []Param
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/api"
apiV1 "github.com/yusing/godoxy/internal/api/v1"
agentApi "github.com/yusing/godoxy/internal/api/v1/agent"
@@ -128,25 +129,31 @@ func listenDebugServer() {
mux.mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="50" y="50" text-anchor="middle" dominant-baseline="middle">🐙</text></svg>`))
fmt.Fprint(w, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="50" y="50" text-anchor="middle" dominant-baseline="middle">🐙</text></svg>`)
})
mux.HandleFunc("Auth block page", "GET", "/auth/block", AuthBlockPageHandler)
mux.HandleFunc("Idlewatcher loading page", "GET", idlewatcherTypes.PathPrefix, idlewatcher.DebugHandler)
apiHandler := newApiHandler(mux)
apiHandler := newAPIHandler(mux)
mux.mux.HandleFunc("/api/v1/", apiHandler.ServeHTTP)
mux.Finalize()
go http.ListenAndServe(":7777", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Expires", "0")
mux.mux.ServeHTTP(w, r)
}))
go func() {
//nolint:gosec
err := http.ListenAndServe(":7777", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Expires", "0")
mux.mux.ServeHTTP(w, r)
}))
if err != nil {
log.Err(err).Msg("Error starting debug server")
}
}()
}
func newApiHandler(debugMux *debugMux) *gin.Engine {
func newAPIHandler(debugMux *debugMux) *gin.Engine {
r := gin.New()
r.Use(api.ErrorHandler())
r.Use(api.ErrorLoggingMiddleware())

View File

@@ -0,0 +1,46 @@
services:
netbird-dashboard:
image: netbirdio/dashboard:latest
container_name: netbird-dashboard
restart: unless-stopped
networks: [netbird-net]
env_file: dashboard.env
labels:
proxy.aliases: netbird
proxy.#1.port: 80
proxy.#1.scheme: http
proxy.#1.homepage.name: NetBird
proxy.#1.homepage.icon: "@selfhst/netbird.svg"
proxy.#1.homepage.category: networking
proxy.#1.rules: | # https://docs.netbird.io/selfhosted/configuration-files
path glob(/signalexchange.SignalExchange/**) | path glob(/management.ManagementService/**) | path glob(/management.ProxyService/**) {
route netbird-grpc
}
path glob(/relay*) | path glob(/ws-proxy/**) | path glob(/api*) | path glob(/oauth2*) {
route netbird-api
}
default {
pass
}
netbird-server:
image: netbirdio/netbird-server:latest
container_name: netbird-server
restart: unless-stopped
networks:
- netbird-net
volumes:
- netbird_data:/var/lib/netbird
- ./config.yaml:/etc/netbird/config.yaml
command: ["--config", "/etc/netbird/config.yaml"]
labels:
proxy.aliases: netbird-api, netbird-grpc
proxy.*.port: 80
proxy.*.homepage.show: false
proxy.#1.scheme: http
proxy.#2.scheme: h2c
networks:
netbird-net:
volumes:
netbird_data:

34
go.mod
View File

@@ -21,14 +21,15 @@ replace (
require (
github.com/PuerkitoBio/goquery v1.11.0 // parsing HTML for extract fav icon; modify_html middleware
github.com/cenkalti/backoff/v5 v5.0.3 // backoff for retrying operations
github.com/coreos/go-oidc/v3 v3.17.0 // oidc authentication
github.com/fsnotify/fsnotify v1.9.0 // file watcher
github.com/gin-gonic/gin v1.11.0 // api server
github.com/go-acme/lego/v4 v4.31.0 // acme client
github.com/go-acme/lego/v4 v4.32.0 // acme client
github.com/go-playground/validator/v10 v10.30.1 // validator
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
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/gotify/server/v2 v2.9.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.11.0 // proxy protocol support
github.com/puzpuzpuz/xsync/v4 v4.4.0 // lock free map for concurrent operations
@@ -47,7 +48,7 @@ require (
github.com/docker/cli v29.2.1+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.1 // jwt authentication
github.com/luthermonson/go-proxmox v0.3.2 // proxmox API client
github.com/luthermonson/go-proxmox v0.4.0 // proxmox API client
github.com/moby/moby/api v1.52.0 // docker API
github.com/moby/moby/client v0.2.1 // docker client
github.com/oschwald/maxminddb-golang v1.13.1 // maxminddb for geoip database
@@ -57,17 +58,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-20260211033321-22f03488e998
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260211033321-22f03488e998
github.com/yusing/godoxy/agent v0.0.0-20260224071728-0eba04510480
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260224071728-0eba04510480
github.com/yusing/gointernals v0.2.0
github.com/yusing/goutils v0.7.0
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260211095624-f5a276d5c58b
github.com/yusing/goutils/http/websocket v0.0.0-20260211095624-f5a276d5c58b
github.com/yusing/goutils/server v0.0.0-20260211095624-f5a276d5c58b
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260223150038-3be815cb6e3b
github.com/yusing/goutils/http/websocket v0.0.0-20260223150038-3be815cb6e3b
github.com/yusing/goutils/server v0.0.0-20260223150038-3be815cb6e3b
)
require (
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth v0.18.2 // 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
@@ -87,7 +88,7 @@ require (
github.com/djherbis/times v1.6.0 // indirect
github.com/docker/go-connections v0.6.0
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -141,8 +142,8 @@ require (
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/api v0.266.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/api v0.268.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
@@ -155,7 +156,6 @@ require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/boombuler/barcode v1.1.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
github.com/containerd/errdefs v1.0.0 // indirect
@@ -165,7 +165,7 @@ require (
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-querystring v1.2.0 // indirect
@@ -173,10 +173,10 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/linode/linodego v1.65.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/nrdcg/goinwx v0.12.0 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.1 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/logging v0.2.4 // indirect

40
go.sum
View File

@@ -1,5 +1,5 @@
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 v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
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=
@@ -82,8 +82,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
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=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -100,8 +100,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-acme/lego/v4 v4.31.0 h1:gd4oUYdfs83PR1/SflkNdit9xY1iul2I4EystnU8NXM=
github.com/go-acme/lego/v4 v4.31.0/go.mod h1:m6zcfX/zcbMYDa8s6AnCMnoORWNP8Epnei+6NBCTUGs=
github.com/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -122,8 +122,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -157,8 +157,8 @@ github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotify/server/v2 v2.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA=
github.com/gotify/server/v2 v2.8.0/go.mod h1:6ci5adxcE2hf1v+2oowKiQmixOxXV8vU+CRLKP6sqZA=
github.com/gotify/server/v2 v2.9.0 h1:2zRCl28wkq0oc6YNbyJS2n0dDOOVvOS3Oez5AG2ij54=
github.com/gotify/server/v2 v2.9.0/go.mod h1:249wwlUqHTr0QsiKARGtFVqds0pNLIMjYLinHyMACdQ=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
@@ -195,8 +195,8 @@ github.com/linode/linodego v1.65.0 h1:SdsuGD8VSsPWeShXpE7ihl5vec+fD3MgwhnfYC/rj7
github.com/linode/linodego v1.65.0/go.mod h1:tOFiTErdjkbVnV+4S0+NmIE9dqqZUEM2HsJaGu8wMh8=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
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/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
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=
@@ -227,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.108.1 h1:3oOIAQ9Fd2qTKTS/VlWmvKyBPKKhXBcCXjRZqOUypI4=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.1/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1 h1:2H75475moAv1hVVYlOk815KfqeiFCiQ7ovqn3OnN6FY=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1/go.mod h1:9HGOXiiQxcsG+4amgdr4xBIMq6IchdLW/nQDyZz07IE=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 h1:OWijzl3nHUApvTivl+3+78dbBwmyEHOnb+W9m6ixGbk=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 h1:9LsjN/zaIN7H8JE61NHpbWhxF0UGY96+kMlk3g8OvGU=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2/go.mod h1:32vZH06TuwZSn+IDMO1qcDvC2vHVlzUALCwXGWPA+dc=
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=
@@ -447,14 +447,14 @@ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0
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.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk=
google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
google.golang.org/api v0.268.0 h1:hgA3aS4lt9rpF5RCCkX0Q2l7DvHgvlb53y4T4u6iKkA=
google.golang.org/api v0.268.0/go.mod h1:HXMyMH496wz+dAJwD/GkAPLd3ZL33Kh0zEG32eNvy9w=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

Submodule goutils updated: 494ab85a33...4912690d40

View File

@@ -1,4 +1,4 @@
# ACL (Access Control List)
# internal/acl
Access control at the TCP connection level with IP/CIDR, timezone, and country-based filtering.

View File

@@ -4,7 +4,7 @@ import "context"
type ContextKey struct{}
func SetCtx(ctx interface{ SetValue(any, any) }, acl ACL) {
func SetCtx(ctx interface{ SetValue(key any, value any) }, acl ACL) {
ctx.SetValue(ContextKey{}, acl)
}

View File

@@ -1,4 +1,4 @@
# Agent Pool
# internal/agentpool
Thread-safe pool for managing remote Docker agent connections.

View File

@@ -34,7 +34,10 @@ func newAgent(cfg *agent.AgentConfig) *Agent {
if addr != agent.AgentHost+":443" {
return nil, &net.AddrError{Err: "invalid address", Addr: addr}
}
return net.DialTimeout("tcp", cfg.Addr, timeout)
dialer := &net.Dialer{
Timeout: timeout,
}
return dialer.Dial("tcp", cfg.Addr)
},
TLSConfig: cfg.TLSConfig(),
ReadTimeout: 5 * time.Second,

View File

@@ -10,24 +10,24 @@ import (
"github.com/bytedance/sonic"
"github.com/gorilla/websocket"
"github.com/valyala/fasthttp"
"github.com/yusing/godoxy/agent/pkg/agent"
agentPkg "github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/goutils/http/reverseproxy"
)
func (cfg *Agent) Do(ctx context.Context, method, endpoint string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, agent.APIBaseURL+endpoint, body)
func (agent *Agent) Do(ctx context.Context, method, endpoint string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, agentPkg.APIBaseURL+endpoint, body)
if err != nil {
return nil, err
}
return cfg.httpClient.Do(req)
return agent.httpClient.Do(req)
}
func (cfg *Agent) Forward(req *http.Request, endpoint string) (*http.Response, error) {
req.URL.Host = agent.AgentHost
func (agent *Agent) Forward(req *http.Request, endpoint string) (*http.Response, error) {
req.URL.Host = agentPkg.AgentHost
req.URL.Scheme = "https"
req.URL.Path = agent.APIEndpointBase + endpoint
req.URL.Path = agentPkg.APIEndpointBase + endpoint
req.RequestURI = ""
resp, err := cfg.httpClient.Do(req)
resp, err := agent.httpClient.Do(req)
if err != nil {
return nil, err
}
@@ -40,20 +40,20 @@ type HealthCheckResponse struct {
Latency time.Duration `json:"latency"`
}
func (cfg *Agent) DoHealthCheck(timeout time.Duration, query string) (ret HealthCheckResponse, err error) {
func (agent *Agent) DoHealthCheck(timeout time.Duration, query string) (ret HealthCheckResponse, err error) {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(agent.APIBaseURL + agent.EndpointHealth + "?" + query)
req.SetRequestURI(agentPkg.APIBaseURL + agentPkg.EndpointHealth + "?" + query)
req.Header.SetMethod(fasthttp.MethodGet)
req.Header.Set("Accept-Encoding", "identity")
req.SetConnectionClose()
start := time.Now()
err = cfg.fasthttpHcClient.DoTimeout(req, resp, timeout)
err = agent.fasthttpHcClient.DoTimeout(req, resp, timeout)
ret.Latency = time.Since(start)
if err != nil {
return ret, err
@@ -71,14 +71,14 @@ func (cfg *Agent) DoHealthCheck(timeout time.Duration, query string) (ret Health
return ret, nil
}
func (cfg *Agent) Websocket(ctx context.Context, endpoint string) (*websocket.Conn, *http.Response, error) {
transport := cfg.Transport()
func (agent *Agent) Websocket(ctx context.Context, endpoint string) (*websocket.Conn, *http.Response, error) {
transport := agent.Transport()
dialer := websocket.Dialer{
NetDialContext: transport.DialContext,
NetDialTLSContext: transport.DialTLSContext,
}
return dialer.DialContext(ctx, agent.APIBaseURL+endpoint, http.Header{
"Host": {agent.AgentHost},
return dialer.DialContext(ctx, agentPkg.APIBaseURL+endpoint, http.Header{
"Host": {agentPkg.AgentHost},
})
}
@@ -86,9 +86,9 @@ func (cfg *Agent) Websocket(ctx context.Context, endpoint string) (*websocket.Co
//
// It will create a new request with the same context, method, and body, but with the agent host and scheme, and the endpoint
// If the request has a query, it will be added to the proxy request's URL
func (cfg *Agent) ReverseProxy(w http.ResponseWriter, req *http.Request, endpoint string) {
rp := reverseproxy.NewReverseProxy("agent", agent.AgentURL, cfg.Transport())
req.URL.Host = agent.AgentHost
func (agent *Agent) ReverseProxy(w http.ResponseWriter, req *http.Request, endpoint string) {
rp := reverseproxy.NewReverseProxy("agent", agentPkg.AgentURL, agent.Transport())
req.URL.Host = agentPkg.AgentHost
req.URL.Scheme = "https"
req.URL.Path = endpoint
req.RequestURI = ""

View File

@@ -1,4 +1,4 @@
# API v1 Package
# internal/api/v1
Implements the v1 REST API handlers for GoDoxy, exposing endpoints for managing routes, Docker containers, certificates, metrics, and system configuration.

View File

@@ -952,7 +952,6 @@
"application/json"
],
"produces": [
"application/json",
"application/godoxy+yaml"
],
"tags": [
@@ -2947,8 +2946,8 @@
}
}
},
"x-id": "routes",
"operationId": "routes"
"x-id": "list",
"operationId": "list"
}
},
"/route/playground": {
@@ -4845,16 +4844,19 @@
"properties": {
"days": {
"type": "integer",
"minimum": 0,
"x-nullable": false,
"x-omitempty": false
},
"keep_size": {
"type": "integer",
"minimum": 0,
"x-nullable": false,
"x-omitempty": false
},
"last": {
"type": "integer",
"minimum": 0,
"x-nullable": false,
"x-omitempty": false
}
@@ -5091,11 +5093,6 @@
"x-nullable": false,
"x-omitempty": false
},
"isResponseRule": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"name": {
"type": "string",
"x-nullable": false,
@@ -5128,10 +5125,7 @@
"$ref": "#/definitions/MockResponse"
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/routeApi.RawRule"
},
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
@@ -6645,11 +6639,13 @@
"x-omitempty": false
},
"fstype": {
"description": "interned",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"path": {
"description": "interned",
"type": "string",
"x-nullable": false,
"x-omitempty": false
@@ -6927,28 +6923,6 @@
"x-nullable": false,
"x-omitempty": false
},
"routeApi.RawRule": {
"type": "object",
"properties": {
"do": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"name": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"on": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"routeApi.RoutesByProvider": {
"type": "object",
"additionalProperties": {
@@ -6996,6 +6970,7 @@
"x-omitempty": false
},
"name": {
"description": "interned",
"type": "string",
"x-nullable": false,
"x-omitempty": false

View File

@@ -767,10 +767,13 @@ definitions:
LogRetention:
properties:
days:
minimum: 0
type: integer
keep_size:
minimum: 0
type: integer
last:
minimum: 0
type: integer
type: object
MetricsPeriod:
@@ -888,8 +891,6 @@ definitions:
properties:
do:
type: string
isResponseRule:
type: boolean
name:
type: string
"on":
@@ -904,9 +905,7 @@ definitions:
mockResponse:
$ref: '#/definitions/MockResponse'
rules:
items:
$ref: '#/definitions/routeApi.RawRule'
type: array
type: string
required:
- rules
type: object
@@ -1690,8 +1689,10 @@ definitions:
free:
type: integer
fstype:
description: interned
type: string
path:
description: interned
type: string
total:
type: number
@@ -1855,15 +1856,6 @@ definitions:
uptime:
type: string
type: object
routeApi.RawRule:
properties:
do:
type: string
name:
type: string
"on":
type: string
type: object
routeApi.RoutesByProvider:
additionalProperties:
items:
@@ -1886,6 +1878,7 @@ definitions:
high:
type: number
name:
description: interned
type: string
temperature:
type: number
@@ -2572,7 +2565,6 @@ paths:
- FileTypeProvider
- FileTypeMiddleware
produces:
- application/json
- application/godoxy+yaml
responses:
"200":
@@ -3933,7 +3925,7 @@ paths:
tags:
- route
- websocket
x-id: routes
x-id: list
/route/playground:
post:
consumes:

View File

@@ -30,7 +30,7 @@ type GetFileContentRequest struct {
// @Description Get file content
// @Tags file
// @Accept json
// @Produce json,application/godoxy+yaml
// @Produce application/godoxy+yaml
// @Param query query GetFileContentRequest true "Request"
// @Success 200 {string} application/godoxy+yaml "File content"
// @Failure 400 {object} apitypes.ErrorResponse

View File

@@ -4,10 +4,12 @@ import (
"context"
"encoding/json"
"net/http"
"net/url"
"sync/atomic"
"time"
"github.com/bytedance/sonic"
"github.com/cenkalti/backoff/v5"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/agent"
@@ -35,6 +37,11 @@ type bytesFromPool struct {
release func([]byte)
}
type systemInfoData struct {
agentName string
systemInfo any
}
// @x-id "all_system_info"
// @BasePath /api/v1
// @Summary Get system info
@@ -72,91 +79,19 @@ func AllSystemInfo(c *gin.Context) {
defer manager.Close()
query := c.Request.URL.Query()
queryEncoded := c.Request.URL.Query().Encode()
type SystemInfoData struct {
AgentName string
SystemInfo any
}
queryEncoded := query.Encode()
// leave 5 extra slots for buffering in case new agents are added.
dataCh := make(chan SystemInfoData, 1+agentpool.Num()+5)
defer close(dataCh)
dataCh := make(chan systemInfoData, 1+agentpool.Num()+5)
ticker := time.NewTicker(req.Interval)
defer ticker.Stop()
go func() {
for {
select {
case <-manager.Done():
return
case data := <-dataCh:
err := marshalSystemInfo(manager, data.AgentName, data.SystemInfo)
if err != nil {
manager.Close()
return
}
}
}
}()
// processing function for one round.
doRound := func() (bool, error) {
var numErrs atomic.Int32
totalAgents := int32(1) // myself
var errs gperr.Group
// get system info for me and all agents in parallel.
errs.Go(func() error {
data, err := systeminfo.Poller.GetRespData(req.Period, query)
if err != nil {
numErrs.Add(1)
return gperr.PrependSubject(err, "Main server")
}
select {
case <-manager.Done():
return nil
case dataCh <- SystemInfoData{
AgentName: "GoDoxy",
SystemInfo: data,
}:
}
return nil
})
for _, a := range agentpool.Iter() {
totalAgents++
errs.Go(func() error {
data, err := getAgentSystemInfoWithRetry(manager.Context(), a, queryEncoded)
if err != nil {
numErrs.Add(1)
return gperr.PrependSubject(err, "Agent "+a.Name)
}
select {
case <-manager.Done():
return nil
case dataCh <- SystemInfoData{
AgentName: a.Name,
SystemInfo: data,
}:
}
return nil
})
}
err := errs.Wait().Error()
return numErrs.Load() == totalAgents, err
}
go streamSystemInfo(manager, dataCh)
// write system info immediately once.
if shouldContinue, err := doRound(); err != nil {
if !shouldContinue {
c.Error(apitypes.InternalServerError(err, "failed to get all system info"))
return
}
if hasSuccess, err := collectSystemInfoRound(manager, req, query, queryEncoded, dataCh); handleRoundResult(c, hasSuccess, err, false) {
return
}
// then continue on the ticker.
@@ -165,17 +100,95 @@ func AllSystemInfo(c *gin.Context) {
case <-manager.Done():
return
case <-ticker.C:
if shouldContinue, err := doRound(); err != nil {
if !shouldContinue {
c.Error(apitypes.InternalServerError(err, "failed to get all system info"))
return
}
log.Warn().Err(err).Msg("failed to get some system info")
if hasSuccess, err := collectSystemInfoRound(manager, req, query, queryEncoded, dataCh); handleRoundResult(c, hasSuccess, err, true) {
return
}
}
}
}
func streamSystemInfo(manager *websocket.Manager, dataCh <-chan systemInfoData) {
for {
select {
case <-manager.Done():
return
case data := <-dataCh:
err := marshalSystemInfo(manager, data.agentName, data.systemInfo)
if err != nil {
manager.Close()
return
}
}
}
}
func queueSystemInfo(manager *websocket.Manager, dataCh chan<- systemInfoData, data systemInfoData) {
select {
case <-manager.Done():
case dataCh <- data:
}
}
func collectSystemInfoRound(
manager *websocket.Manager,
req AllSystemInfoRequest,
query url.Values,
queryEncoded string,
dataCh chan<- systemInfoData,
) (hasSuccess bool, err error) {
var numErrs atomic.Int32
totalAgents := int32(1) // myself
var errs gperr.Group
// get system info for me and all agents in parallel.
errs.Go(func() error {
data, err := systeminfo.Poller.GetRespData(req.Period, query)
if err != nil {
numErrs.Add(1)
return gperr.PrependSubject(err, "Main server")
}
queueSystemInfo(manager, dataCh, systemInfoData{
agentName: "GoDoxy",
systemInfo: data,
})
return nil
})
for _, a := range agentpool.Iter() {
totalAgents++
errs.Go(func() error {
data, err := getAgentSystemInfoWithRetry(manager.Context(), a, queryEncoded)
if err != nil {
numErrs.Add(1)
return gperr.PrependSubject(err, "Agent "+a.Name)
}
queueSystemInfo(manager, dataCh, systemInfoData{
agentName: a.Name,
systemInfo: data,
})
return nil
})
}
err = errs.Wait().Error()
return numErrs.Load() < totalAgents, err
}
func handleRoundResult(c *gin.Context, hasSuccess bool, err error, logPartial bool) (stop bool) {
if err == nil {
return false
}
if !hasSuccess {
c.Error(apitypes.InternalServerError(err, "failed to get all system info"))
return true
}
if logPartial {
log.Warn().Err(err).Msg("failed to get some system info")
}
return false
}
func getAgentSystemInfo(ctx context.Context, a *agentpool.Agent, query string) (bytesFromPool, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
@@ -197,35 +210,26 @@ func getAgentSystemInfo(ctx context.Context, a *agentpool.Agent, query string) (
func getAgentSystemInfoWithRetry(ctx context.Context, a *agentpool.Agent, query string) (bytesFromPool, error) {
const maxRetries = 3
var lastErr error
for attempt := range maxRetries {
// Apply backoff delay for retries (not for first attempt)
if attempt > 0 {
delay := max((1<<attempt)*time.Second, 5*time.Second)
select {
case <-ctx.Done():
return bytesFromPool{}, ctx.Err()
case <-time.After(delay):
}
}
const retryDelay = 5 * time.Second
var attempt int
data, err := backoff.Retry(ctx, func() (bytesFromPool, error) {
attempt++
data, err := getAgentSystemInfo(ctx, a, query)
if err == nil {
return data, nil
}
lastErr = err
log.Debug().Str("agent", a.Name).Int("attempt", attempt+1).Str("error", err.Error()).Msg("Agent request attempt failed")
// Don't retry on context cancellation
if ctx.Err() != nil {
return bytesFromPool{}, ctx.Err()
}
log.Err(err).Str("agent", a.Name).Int("attempt", attempt).Msg("Agent request attempt failed")
return bytesFromPool{}, err
},
backoff.WithBackOff(backoff.NewConstantBackOff(retryDelay)),
backoff.WithMaxTries(maxRetries),
)
if err != nil {
return bytesFromPool{}, err
}
return bytesFromPool{}, lastErr
return data, nil
}
func marshalSystemInfo(ws *websocket.Manager, agentName string, systemInfo any) error {

View File

@@ -8,6 +8,7 @@ import (
"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"
)
@@ -15,10 +16,10 @@ import (
// 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)
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:"omitempty,min=1,max=1000"` // Limit output lines (1-1000)
} // @name ProxmoxJournalctlRequest
// @x-id "journalctl"
@@ -40,6 +41,11 @@ type JournalctlRequest struct {
// @Router /proxmox/journalctl/{node}/{vmid} [get]
// @Router /proxmox/journalctl/{node}/{vmid}/{service} [get]
func Journalctl(c *gin.Context) {
if !httpheaders.IsWebsocket(c.Request.Header) {
c.JSON(http.StatusBadRequest, apitypes.Error("websocket required"))
return
}
var request JournalctlRequest
uriErr := c.ShouldBindUri(&request)
queryErr := c.ShouldBindQuery(&request)
@@ -48,6 +54,10 @@ func Journalctl(c *gin.Context) {
return
}
if request.Limit == nil {
request.Limit = new(100)
}
node, ok := proxmox.Nodes.Get(request.Node)
if !ok {
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))

View File

@@ -7,6 +7,7 @@ import (
"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"
)
@@ -34,6 +35,11 @@ type TailRequest struct {
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
// @Router /proxmox/tail [get]
func Tail(c *gin.Context) {
if !httpheaders.IsWebsocket(c.Request.Header) {
c.JSON(http.StatusBadRequest, apitypes.Error("websocket required"))
return
}
var request TailRequest
if err := c.ShouldBindQuery(&request); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))

View File

@@ -15,7 +15,7 @@ import (
type RouteType route.Route // @name Route
// @x-id "routes"
// @x-id "list"
// @BasePath /api/v1
// @Summary List routes
// @Description List routes

View File

@@ -9,6 +9,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/goccy/go-yaml"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/route/rules"
apitypes "github.com/yusing/goutils/apitypes"
@@ -23,7 +24,7 @@ type RawRule struct {
}
type PlaygroundRequest struct {
Rules []RawRule `json:"rules" binding:"required"`
Rules string `json:"rules" binding:"required"`
MockRequest MockRequest `json:"mockRequest"`
MockResponse MockResponse `json:"mockResponse"`
} // @name PlaygroundRequest
@@ -64,7 +65,6 @@ type ParsedRule struct {
On string `json:"on"`
Do string `json:"do"`
ValidationError error `json:"validationError,omitempty"` // we need the structured error, not the plain string
IsResponseRule bool `json:"isResponseRule"`
} // @name ParsedRule
type FinalRequest struct {
@@ -256,7 +256,35 @@ func handlerWithRecover(w http.ResponseWriter, r *http.Request, h http.HandlerFu
h(w, r)
}
func parseRules(rawRules []RawRule) ([]ParsedRule, rules.Rules, error) {
func parseRules(config string) ([]ParsedRule, rules.Rules, error) {
config = strings.TrimSpace(config)
if config == "" {
return []ParsedRule{}, nil, nil
}
var rawRules []RawRule
if err := yaml.Unmarshal([]byte(config), &rawRules); err == nil && len(rawRules) > 0 {
return parseRawRules(rawRules)
}
var rulesList rules.Rules
if err := rulesList.Parse(config); err != nil {
return nil, nil, err
}
parsedRules := make([]ParsedRule, 0, len(rulesList))
for _, rule := range rulesList {
parsedRules = append(parsedRules, ParsedRule{
Name: rule.Name,
On: rule.On.String(),
Do: rule.Do.String(),
})
}
return parsedRules, rulesList, nil
}
func parseRawRules(rawRules []RawRule) ([]ParsedRule, rules.Rules, error) {
parsedRules := make([]ParsedRule, 0, len(rawRules))
rulesList := make(rules.Rules, 0, len(rawRules))
@@ -298,7 +326,6 @@ func parseRules(rawRules []RawRule) ([]ParsedRule, rules.Rules, error) {
On: onStr,
Do: doStr,
ValidationError: validationErr,
IsResponseRule: rule.IsResponseRule(),
})
// Only add valid rules to execution list

View File

@@ -22,13 +22,10 @@ func TestPlayground(t *testing.T) {
{
name: "simple path matching rule",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "test rule",
On: "path /api",
Do: "pass",
},
},
Rules: `- name: test rule
on: path /api
do: pass
`,
MockRequest: MockRequest{
Method: "GET",
Path: "/api",
@@ -53,13 +50,10 @@ func TestPlayground(t *testing.T) {
{
name: "header matching rule",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "check user agent",
On: "header User-Agent Chrome",
Do: "error 403 Forbidden",
},
},
Rules: `- name: check user agent
on: header User-Agent Chrome
do: error 403 Forbidden
`,
MockRequest: MockRequest{
Method: "GET",
Path: "/",
@@ -90,13 +84,10 @@ func TestPlayground(t *testing.T) {
{
name: "invalid rule syntax",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "bad rule",
On: "invalid_checker something",
Do: "pass",
},
},
Rules: `- name: bad rule
on: invalid_checker something
do: pass
`,
MockRequest: MockRequest{
Method: "GET",
Path: "/",
@@ -115,13 +106,10 @@ func TestPlayground(t *testing.T) {
{
name: "rewrite path rule",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "rewrite rule",
On: "path glob(/api/*)",
Do: "rewrite /api/ /v1/",
},
},
Rules: `- name: rewrite rule
on: path glob(/api/*)
do: rewrite /api/ /v1/
`,
MockRequest: MockRequest{
Method: "GET",
Path: "/api/users",
@@ -148,13 +136,10 @@ func TestPlayground(t *testing.T) {
{
name: "method matching rule",
request: PlaygroundRequest{
Rules: []RawRule{
{
Name: "block POST",
On: "method POST",
Do: `error "405" "Method Not Allowed"`,
},
},
Rules: `- name: block POST
on: method POST
do: error "405" "Method Not Allowed"
`,
MockRequest: MockRequest{
Method: "POST",
Path: "/api",
@@ -173,6 +158,63 @@ func TestPlayground(t *testing.T) {
}
},
},
{
name: "block syntax default rule",
request: PlaygroundRequest{
Rules: `default {
pass
}`,
MockRequest: MockRequest{
Method: "GET",
Path: "/",
},
},
wantStatusCode: http.StatusOK,
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
if len(resp.ParsedRules) != 1 {
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
}
if resp.ParsedRules[0].ValidationError != nil {
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
}
if !resp.UpstreamCalled {
t.Error("expected upstream to be called")
}
},
},
{
name: "block syntax conditional rule",
request: PlaygroundRequest{
Rules: `header User-Agent Chrome {
error 403 Forbidden
}`,
MockRequest: MockRequest{
Method: "GET",
Path: "/",
Headers: map[string][]string{
"User-Agent": {"Chrome"},
},
},
},
wantStatusCode: http.StatusOK,
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
if len(resp.ParsedRules) != 1 {
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
}
if resp.ParsedRules[0].ValidationError != nil {
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
}
if len(resp.MatchedRules) != 1 {
t.Errorf("expected 1 matched rule, got %d", len(resp.MatchedRules))
}
if resp.FinalResponse.StatusCode != http.StatusForbidden {
t.Errorf("expected status 403, got %d", resp.FinalResponse.StatusCode)
}
if resp.UpstreamCalled {
t.Error("expected upstream not to be called")
}
},
},
}
for _, tt := range tests {

View File

@@ -1,4 +1,4 @@
# Authentication
# internal/auth
Authentication providers supporting OIDC and username/password authentication with JWT-based sessions.

View File

@@ -8,7 +8,6 @@ import (
"time"
"github.com/yusing/godoxy/internal/common"
strutils "github.com/yusing/goutils/strings"
)
var (
@@ -70,12 +69,12 @@ func cookieDomain(r *http.Request) string {
}
}
parts := strutils.SplitRune(reqHost, '.')
parts := strings.Split(reqHost, ".")
if len(parts) < 2 {
return ""
}
parts[0] = ""
return strutils.JoinRune(parts, '.')
return strings.Join(parts, ".")
}
func SetTokenCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) {

View File

@@ -1,4 +1,4 @@
# Autocert Package
# internal/autocert
Automated SSL certificate management using the ACME protocol (Let's Encrypt and compatible CAs).

View File

@@ -23,33 +23,35 @@ import (
strutils "github.com/yusing/goutils/strings"
)
type ConfigExtra Config
type Config struct {
Email string `json:"email,omitempty"`
Domains []string `json:"domains,omitempty"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
Extra []ConfigExtra `json:"extra,omitempty"`
ACMEKeyPath string `json:"acme_key_path,omitempty"` // shared by all extra providers with the same CA directory URL
Provider string `json:"provider,omitempty"`
Options map[string]strutils.Redacted `json:"options,omitempty"`
type (
ConfigExtra Config
Config struct {
Email string `json:"email,omitempty"`
Domains []string `json:"domains,omitempty"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
Extra []ConfigExtra `json:"extra,omitempty"`
ACMEKeyPath string `json:"acme_key_path,omitempty"` // shared by all extra providers with the same CA directory URL
Provider string `json:"provider,omitempty"`
Options map[string]strutils.Redacted `json:"options,omitempty"`
Resolvers []string `json:"resolvers,omitempty"`
Resolvers []string `json:"resolvers,omitempty"`
// Custom ACME CA
CADirURL string `json:"ca_dir_url,omitempty"`
CACerts []string `json:"ca_certs,omitempty"`
// Custom ACME CA
CADirURL string `json:"ca_dir_url,omitempty"`
CACerts []string `json:"ca_certs,omitempty"`
// EAB
EABKid string `json:"eab_kid,omitempty" validate:"required_with=EABHmac"`
EABHmac string `json:"eab_hmac,omitempty" validate:"required_with=EABKid"` // base64 encoded
// EAB
EABKid string `json:"eab_kid,omitempty" validate:"required_with=EABHmac"`
EABHmac string `json:"eab_hmac,omitempty" validate:"required_with=EABKid"` // base64 encoded
HTTPClient *http.Client `json:"-"` // for tests only
HTTPClient *http.Client `json:"-"` // for tests only
challengeProvider challenge.Provider
challengeProvider challenge.Provider
idx int // 0: main, 1+: extra[i]
}
idx int // 0: main, 1+: extra[i]
}
)
var (
ErrMissingField = gperr.New("missing field")

View File

@@ -68,7 +68,7 @@ func TestMultipleCertificatesLifecycle(t *testing.T) {
require.Equal(t, "custom", cfg.Extra[1].Provider)
/* track cert requests for all configs */
os.MkdirAll("certs", 0755)
os.MkdirAll("certs", 0o755)
defer os.RemoveAll("certs")
err = provider.ObtainCertIfNotExistsAll()

View File

@@ -1,4 +1,4 @@
# Configuration Management
# internal/config
Centralized YAML configuration management with thread-safe state access and provider initialization.

View File

@@ -1,4 +1,4 @@
# Configuration Query
# internal/config/query
Read-only access to the active configuration state, including route providers and system statistics.
@@ -149,7 +149,7 @@ No metrics are currently exposed.
## Performance Characteristics
- O(n) where n is number of providers for provider queries
- O(n * m) where m is routes per provider for route search
- O(n \* m) where m is routes per provider for route search
- O(n) for statistics aggregation
- No locking required (uses atomic load)

View File

@@ -1,4 +1,4 @@
# DNS Providers
# internal/dnsproviders
DNS provider integrations for Let's Encrypt certificate management via the lego library.

View File

@@ -5,12 +5,12 @@ go 1.26.0
replace github.com/yusing/godoxy => ../..
require (
github.com/go-acme/lego/v4 v4.31.0
github.com/yusing/godoxy v0.25.3
github.com/go-acme/lego/v4 v4.32.0
github.com/yusing/godoxy v0.26.0
)
require (
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth v0.18.2 // 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
@@ -40,7 +40,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
@@ -50,7 +50,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gotify/server/v2 v2.8.0 // indirect
github.com/gotify/server/v2 v2.9.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -65,8 +65,8 @@ require (
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.108.1 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 // 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
@@ -98,8 +98,8 @@ require (
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/api v0.266.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/api v0.268.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect

View File

@@ -1,5 +1,5 @@
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 v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
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=
@@ -62,8 +62,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-acme/lego/v4 v4.31.0 h1:gd4oUYdfs83PR1/SflkNdit9xY1iul2I4EystnU8NXM=
github.com/go-acme/lego/v4 v4.31.0/go.mod h1:m6zcfX/zcbMYDa8s6AnCMnoORWNP8Epnei+6NBCTUGs=
github.com/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -81,8 +81,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -107,8 +107,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1Znvmczt
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/gotify/server/v2 v2.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA=
github.com/gotify/server/v2 v2.8.0/go.mod h1:6ci5adxcE2hf1v+2oowKiQmixOxXV8vU+CRLKP6sqZA=
github.com/gotify/server/v2 v2.9.0 h1:2zRCl28wkq0oc6YNbyJS2n0dDOOVvOS3Oez5AG2ij54=
github.com/gotify/server/v2 v2.9.0/go.mod h1:249wwlUqHTr0QsiKARGtFVqds0pNLIMjYLinHyMACdQ=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@@ -150,10 +150,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.108.1 h1:3oOIAQ9Fd2qTKTS/VlWmvKyBPKKhXBcCXjRZqOUypI4=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.1/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1 h1:2H75475moAv1hVVYlOk815KfqeiFCiQ7ovqn3OnN6FY=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.1/go.mod h1:9HGOXiiQxcsG+4amgdr4xBIMq6IchdLW/nQDyZz07IE=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 h1:OWijzl3nHUApvTivl+3+78dbBwmyEHOnb+W9m6ixGbk=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 h1:9LsjN/zaIN7H8JE61NHpbWhxF0UGY96+kMlk3g8OvGU=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2/go.mod h1:32vZH06TuwZSn+IDMO1qcDvC2vHVlzUALCwXGWPA+dc=
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=
@@ -249,14 +249,14 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk=
google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
google.golang.org/api v0.268.0 h1:hgA3aS4lt9rpF5RCCkX0Q2l7DvHgvlb53y4T4u6iKkA=
google.golang.org/api v0.268.0/go.mod h1:HXMyMH496wz+dAJwD/GkAPLd3ZL33Kh0zEG32eNvy9w=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -1,4 +1,4 @@
# Docker Integration
# internal/docker
Docker container discovery, connection management, and label-based route configuration.

View File

@@ -46,8 +46,8 @@ func (c containerHelper) getMounts() *ordered.Map[string, string] {
}
func (c containerHelper) parseImage() *types.ContainerImage {
colonSep := strutils.SplitRune(c.Image, ':')
slashSep := strutils.SplitRune(colonSep[0], '/')
colonSep := strings.Split(c.Image, ":")
slashSep := strings.Split(colonSep[0], "/")
_, sha256, _ := strings.Cut(c.ImageID, ":")
im := &types.ContainerImage{
SHA256: sha256,

View File

@@ -9,7 +9,6 @@ import (
"github.com/goccy/go-yaml"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
strutils "github.com/yusing/goutils/strings"
)
var ErrInvalidLabel = errors.New("invalid label")
@@ -31,7 +30,7 @@ func ParseLabels(labels map[string]string, aliases ...string) (types.LabelMap, e
ExpandWildcard(labels, aliases...)
for lbl, value := range labels {
parts := strutils.SplitRune(lbl, '.')
parts := strings.Split(lbl, ".")
if parts[0] != NSProxy {
continue
}

View File

@@ -1,4 +1,4 @@
# Entrypoint
# internal/entrypoint
The entrypoint package provides the main HTTP entry point for GoDoxy, handling domain-based routing, middleware application, short link matching, access logging, and HTTP server lifecycle management.
@@ -122,9 +122,9 @@ classDiagram
+accessLogger AccessLogger
+findRouteFunc findRouteFunc
+shortLinkMatcher *ShortLinkMatcher
+streamRoutes *pool.Pool[types.StreamRoute]
+excludedRoutes *pool.Pool[types.Route]
+servers *xsync.Map[string, *httpServer]
+streamRoutes *pool.Pool\[types.StreamRoute\]
+excludedRoutes *pool.Pool\[types.Route\]
+servers *xsync.Map\[string, *httpServer\]
+SupportProxyProtocol() bool
+StartAddRoute(r) error
+IterRoutes(yield)
@@ -132,7 +132,7 @@ classDiagram
}
class httpServer {
+routes *pool.Pool[types.HTTPRoute]
+routes *pool.Pool\[types.HTTPRoute\]
+ServeHTTP(w, r)
+AddRoute(route)
+DelRoute(route)
@@ -154,8 +154,8 @@ classDiagram
}
class ShortLinkMatcher {
+fqdnRoutes *xsync.Map[string, string]
+subdomainRoutes *xsync.Map[string, struct{}]
+fqdnRoutes *xsync.Map\[string, string\]
+subdomainRoutes *xsync.Map\[string, emptyStruct\]
+ServeHTTP(w, r)
+AddRoute(alias)
+DelRoute(alias)

View File

@@ -162,10 +162,9 @@ func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.Request
}
func findRouteAnyDomain(routes HTTPRoutes, host string) types.HTTPRoute {
//nolint:modernize
idx := strings.IndexByte(host, '.')
if idx != -1 {
target := host[:idx]
before, _, ok := strings.Cut(host, ".")
if ok {
target := before
if r, ok := routes.Get(target); ok {
return r
}

View File

@@ -6,7 +6,7 @@ import (
type ContextKey struct{}
func SetCtx(ctx interface{ SetValue(any, any) }, ep Entrypoint) {
func SetCtx(ctx interface{ SetValue(key any, value any) }, ep Entrypoint) {
ctx.SetValue(ContextKey{}, ep)
}

View File

@@ -1,4 +1,4 @@
# Health Check Package
# internal/health/check
Low-level health check implementations for different protocols and services in GoDoxy.

View File

@@ -24,12 +24,13 @@ var h2cClient = &http.Client{
},
},
}
var pinger = &fasthttp.Client{
MaxConnDuration: 0,
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
TLSConfig: &tls.Config{
InsecureSkipVerify: true,
InsecureSkipVerify: true, //nolint:gosec
},
MaxConnsPerHost: 1000,
NoDefaultUserAgentHeader: true,
@@ -51,7 +52,7 @@ func HTTP(url *url.URL, method, path string, timeout time.Duration) (types.Healt
respErr := pinger.DoTimeout(req, resp, timeout)
lat := time.Since(start)
return processHealthResponse(lat, respErr, resp.StatusCode)
return processHealthResponse(lat, respErr, resp.StatusCode), nil
}
func H2C(ctx context.Context, url *url.URL, method, path string, timeout time.Duration) (types.HealthCheckResult, error) {
@@ -87,7 +88,7 @@ func H2C(ctx context.Context, url *url.URL, method, path string, timeout time.Du
defer resp.Body.Close()
}
return processHealthResponse(lat, err, func() int { return resp.StatusCode })
return processHealthResponse(lat, err, func() int { return resp.StatusCode }), nil
}
var userAgent = "GoDoxy/" + version.Get().String()
@@ -100,20 +101,20 @@ func setCommonHeaders(setHeader func(key, value string)) {
setHeader("Pragma", "no-cache")
}
func processHealthResponse(lat time.Duration, err error, getStatusCode func() int) (types.HealthCheckResult, error) {
func processHealthResponse(lat time.Duration, err error, getStatusCode func() int) types.HealthCheckResult {
if err != nil {
var tlsErr *tls.CertificateVerificationError
if ok := errors.As(err, &tlsErr); !ok {
return types.HealthCheckResult{
Latency: lat,
Detail: err.Error(),
}, nil
}
}
return types.HealthCheckResult{
Latency: lat,
Healthy: true,
Detail: tlsErr.Error(),
}, nil
}
}
statusCode := getStatusCode()
@@ -121,11 +122,11 @@ func processHealthResponse(lat time.Duration, err error, getStatusCode func() in
return types.HealthCheckResult{
Latency: lat,
Detail: http.StatusText(statusCode),
}, nil
}
}
return types.HealthCheckResult{
Latency: lat,
Healthy: true,
}, nil
}
}

View File

@@ -1,4 +1,4 @@
# Health Monitor Package
# internal/health/monitor
Route health monitoring with configurable check intervals, retry policies, and notification integration.

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"math/rand"
"net/url"
"strings"
"sync/atomic"
"time"
@@ -199,7 +200,7 @@ func (mon *monitor) Detail() string {
// Name implements HealthMonitor.
func (mon *monitor) Name() string {
parts := strutils.SplitRune(mon.service, '/')
parts := strings.Split(mon.service, "/")
return parts[len(parts)-1]
}
@@ -244,9 +245,11 @@ func (mon *monitor) checkUpdateHealth() error {
// change of status
if result.Healthy != (lastStatus == types.StatusHealthy) {
if result.Healthy {
mon.notifyServiceUp(&logger, &result)
mon.numConsecFailures.Store(0)
mon.downNotificationSent.Store(false) // Reset notification state when service comes back up
if mon.downNotificationSent.Load() { // Only notify if the service down has been notified
mon.notifyServiceUp(&logger, &result)
mon.downNotificationSent.Store(false) // Reset notification state when service comes back up
}
} else if mon.config.Retries < 0 {
// immediate notification when retries < 0
mon.notifyServiceDown(&logger, &result)

View File

@@ -171,12 +171,12 @@ func TestNotification_ServiceRecoversBeforeThreshold(t *testing.T) {
err = mon.checkUpdateHealth()
require.NoError(t, err)
// Should have 1 up notification, but no down notification
// because threshold was never met
// Should have no notifications because threshold was never met.
// Recovery notification is only sent after a down notification was sent.
up, down, last := tracker.getStats()
require.Equal(t, 0, down)
require.Equal(t, 1, up)
require.Equal(t, "up", last)
require.Equal(t, 0, up)
require.Empty(t, last)
}
func TestNotification_ConsecutiveFailureReset(t *testing.T) {
@@ -210,10 +210,11 @@ func TestNotification_ConsecutiveFailureReset(t *testing.T) {
err = mon.checkUpdateHealth()
require.NoError(t, err)
// Should have 1 up notification, consecutive failures should reset
// Should have no notifications, consecutive failures should reset.
// Recovery notification is only sent after a down notification was sent.
up, down, _ := tracker.getStats()
require.Equal(t, 0, down)
require.Equal(t, 1, up)
require.Equal(t, 0, up)
// Go down again - consecutive counter should start from 0
mon.checkHealth = func(u *url.URL) (types.HealthCheckResult, error) {
@@ -227,7 +228,7 @@ func TestNotification_ConsecutiveFailureReset(t *testing.T) {
// Should still have no down notifications (need 2 consecutive)
up, down, _ = tracker.getStats()
require.Equal(t, 0, down)
require.Equal(t, 1, up)
require.Equal(t, 0, up)
// Second consecutive failure - should trigger notification
err = mon.checkUpdateHealth()
@@ -236,7 +237,7 @@ func TestNotification_ConsecutiveFailureReset(t *testing.T) {
// Now should have down notification
up, down, last := tracker.getStats()
require.Equal(t, 1, down)
require.Equal(t, 1, up)
require.Equal(t, 0, up)
require.Equal(t, "down", last)
}
@@ -279,11 +280,11 @@ func TestNotification_ContextCancellation(t *testing.T) {
require.Equal(t, 0, up)
}
func TestImmediateUpNotification(t *testing.T) {
func TestImmediateUpNotificationAfterDownNotification(t *testing.T) {
config := types.HealthCheckConfig{
Interval: 100 * time.Millisecond,
Timeout: 50 * time.Millisecond,
Retries: 2, // NotifyAfter should not affect up notifications
Retries: 2,
}
mon, tracker := createTestMonitor(config, func(u *url.URL) (types.HealthCheckResult, error) {
@@ -292,6 +293,7 @@ func TestImmediateUpNotification(t *testing.T) {
// Start unhealthy
mon.status.Store(types.StatusUnhealthy)
mon.downNotificationSent.Store(true)
// Set to healthy
mon.checkHealth = func(u *url.URL) (types.HealthCheckResult, error) {
@@ -302,7 +304,7 @@ func TestImmediateUpNotification(t *testing.T) {
err := mon.checkUpdateHealth()
require.NoError(t, err)
// Up notification should happen immediately regardless of NotifyAfter setting
// Up notification should happen immediately once a prior down notification exists.
require.Equal(t, types.StatusHealthy, mon.Status())
// Should have exactly 1 up notification immediately

View File

@@ -1,4 +1,4 @@
# Homepage
# internal/homepage
The homepage package provides the GoDoxy WebUI dashboard with support for categories, favorites, widgets, dynamic item configuration, and icon management.

View File

@@ -9,10 +9,6 @@ import (
expect "github.com/yusing/goutils/testing"
)
func strPtr(s string) *string {
return &s
}
func TestOverrideItem(t *testing.T) {
a := &Item{
Alias: "foo",
@@ -20,7 +16,7 @@ func TestOverrideItem(t *testing.T) {
Show: false,
Name: "Foo",
Icon: &icons.URL{
FullURL: strPtr("/favicon.ico"),
FullURL: new("/favicon.ico"),
Source: icons.SourceRelative,
},
Category: "App",
@@ -31,7 +27,7 @@ func TestOverrideItem(t *testing.T) {
Name: "Bar",
Category: "Test",
Icon: &icons.URL{
FullURL: strPtr("@walkxcode/example.png"),
FullURL: new("@walkxcode/example.png"),
Source: icons.SourceWalkXCode,
},
}

View File

@@ -1,4 +1,4 @@
# Icons Package
# internal/homepage/icons
Icon URL parsing, fetching, and listing for the homepage dashboard.

View File

@@ -3,10 +3,12 @@ package iconfetch
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"math"
"mime"
"net/http"
"net/url"
"slices"
@@ -17,7 +19,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/vincent-petithory/dataurl"
"github.com/yusing/godoxy/internal/homepage/icons"
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/cache"
httputils "github.com/yusing/goutils/http"
@@ -76,26 +77,42 @@ func FetchFavIconFromURL(ctx context.Context, iconURL *icons.URL) (Result, error
return FetchResultWithErrorf(http.StatusBadRequest, "invalid icon source")
}
var fetchIconClient = http.Client{
Timeout: faviconFetchTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec
},
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (Result, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err)
}
resp, err := gphttp.Do(req)
if err == nil {
defer resp.Body.Close()
} else {
resp, err := fetchIconClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return FetchResultWithErrorf(http.StatusBadGateway, "request timeout")
}
return FetchResultWithErrorf(http.StatusBadGateway, "connection error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return FetchResultWithErrorf(resp.StatusCode, "upstream error: http %d", resp.StatusCode)
}
ct, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if ct != "" && !strings.HasPrefix(ct, "image/") {
return FetchResultWithErrorf(http.StatusNotFound, "not an image")
}
icon, err := io.ReadAll(resp.Body)
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to read response body: %w", err)
@@ -105,10 +122,15 @@ var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (
return FetchResultWithErrorf(http.StatusNotFound, "empty icon")
}
res := Result{Icon: icon}
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
res.contentType = contentType
if ct == "" {
ct = http.DetectContentType(icon)
if !strings.HasPrefix(ct, "image/") {
return FetchResultWithErrorf(http.StatusNotFound, "not an image")
}
}
res := Result{Icon: icon}
res.contentType = ct
// else leave it empty
return res, nil
}).WithMaxEntries(200).WithRetriesExponentialBackoff(3).WithTTL(4 * time.Hour).Build()

View File

@@ -56,7 +56,8 @@ func init() {
func InitCache() {
m := make(IconMap)
err := serialization.LoadFileIfExist(common.IconListCachePath, &m, sonic.Unmarshal)
if err != nil {
switch {
case err != nil:
// backward compatible
oldFormat := struct {
Icons IconMap
@@ -70,11 +71,11 @@ func InitCache() {
// store it to disk immediately
_ = serialization.SaveFile(common.IconListCachePath, &m, 0o644, sonic.Marshal)
}
} else if len(m) > 0 {
case len(m) > 0:
log.Info().
Int("icons", len(m)).
Msg("icons loaded")
} else {
default:
if err := updateIcons(m); err != nil {
log.Error().Err(err).Msg("failed to update icons")
}
@@ -142,33 +143,46 @@ func SearchIcons(keyword string, limit int) []*IconMetaSearch {
return a.rank - b.rank
}
var rank int
dashedKeyword := strings.ReplaceAll(keyword, " ", "-")
whitespacedKeyword := strings.ReplaceAll(keyword, "-", " ")
icons := ListAvailableIcons()
for k, icon := range icons {
if strutils.ContainsFold(string(k), keyword) || strutils.ContainsFold(icon.DisplayName, keyword) {
source, ref := k.SourceRef()
var rank int
switch {
case strings.EqualFold(ref, dashedKeyword):
// exact match: best rank, use source as tiebreaker (lower index = higher priority)
rank = 0
} else {
rank = fuzzy.RankMatchFold(keyword, string(k))
case strutils.HasPrefixFold(ref, dashedKeyword):
// prefix match: rank by how much extra the name has (shorter = better)
rank = 100 + len(ref) - len(dashedKeyword)
case strutils.ContainsFold(ref, dashedKeyword) || strutils.ContainsFold(icon.DisplayName, whitespacedKeyword):
// contains match
rank = 500 + len(ref) - len(dashedKeyword)
default:
rank = fuzzy.RankMatchFold(keyword, ref)
if rank == -1 || rank > 3 {
continue
}
rank += 1000
}
source, ref := k.SourceRef()
ranked := &IconMetaSearch{
Source: source,
Ref: ref,
Meta: icon,
rank: rank,
}
// Sorted insert based on rank (lower rank = better match)
insertPos, _ := slices.BinarySearchFunc(results, ranked, sortByRank)
results = slices.Insert(results, insertPos, ranked)
results = append(results, ranked)
if len(results) == searchLimit {
break
}
}
slices.SortStableFunc(results, sortByRank)
// Extract results and limit to the requested count
return results[:min(len(results), limit)]
}
@@ -249,6 +263,8 @@ func httpGetImpl(url string) ([]byte, func([]byte), error) {
}
/*
UpdateWalkxCodeIcons updates the icon map with the icons from walkxcode.
format:
{

View File

@@ -106,21 +106,21 @@ func (u *URL) Parse(v string) error {
func (u *URL) parse(v string, checkExists bool) error {
if v == "" {
return ErrInvalidIconURL
return gperr.PrependSubject(ErrInvalidIconURL, "empty url")
}
slashIndex := strings.Index(v, "/")
if slashIndex == -1 {
return ErrInvalidIconURL
return gperr.PrependSubject(ErrInvalidIconURL, v)
}
beforeSlash := v[:slashIndex]
switch beforeSlash {
case "http:", "https:":
u.FullURL = &v
u.Source = SourceAbsolute
case "@target", "": // @target/favicon.ico, /favicon.ico
case "@target", "": // @target/favicon.ico, /favicon.ico
url := v[slashIndex:]
if url == "/" {
return fmt.Errorf("%w: empty path", ErrInvalidIconURL)
return gperr.PrependSubject(ErrInvalidIconURL, v).Withf("%s", "empty path")
}
u.FullURL = &url
u.Source = SourceRelative
@@ -132,16 +132,16 @@ func (u *URL) parse(v string, checkExists bool) error {
}
parts := strings.Split(v[slashIndex+1:], ".")
if len(parts) != 2 {
return fmt.Errorf("%w: expect %s/<reference>.<format>, e.g. %s/adguard-home.webp", ErrInvalidIconURL, beforeSlash, beforeSlash)
return gperr.PrependSubject(ErrInvalidIconURL, v).Withf("expect %s/<reference>.<format>, e.g. %s/adguard-home.webp", beforeSlash, beforeSlash)
}
reference, format := parts[0], strings.ToLower(parts[1])
if reference == "" || format == "" {
return ErrInvalidIconURL
return gperr.PrependSubject(ErrInvalidIconURL, v).Withf("empty reference or format")
}
switch format {
case "svg", "png", "webp":
default:
return fmt.Errorf("%w: invalid image format, expect svg/png/webp", ErrInvalidIconURL)
return gperr.PrependSubject(ErrInvalidIconURL, v).Withf("invalid image format, expect svg/png/webp")
}
isLight, isDark := false, false
if strings.HasSuffix(reference, "-light") {
@@ -159,7 +159,7 @@ func (u *URL) parse(v string, checkExists bool) error {
IsDark: isDark,
}
if checkExists && !u.HasIcon() {
return fmt.Errorf("%w: no such icon %s.%s from %s", ErrInvalidIconURL, reference, format, u.Source)
return gperr.PrependSubject(ErrInvalidIconURL, v).Withf("no such icon from %s", u.Source)
}
default:
return gperr.PrependSubject(ErrInvalidIconURL, v)

View File

@@ -7,10 +7,6 @@ import (
expect "github.com/yusing/goutils/testing"
)
func strPtr(s string) *string {
return &s
}
func TestIconURL(t *testing.T) {
tests := []struct {
name string
@@ -22,7 +18,7 @@ func TestIconURL(t *testing.T) {
name: "absolute",
input: "http://example.com/icon.png",
wantValue: &URL{
FullURL: strPtr("http://example.com/icon.png"),
FullURL: new("http://example.com/icon.png"),
Source: SourceAbsolute,
},
},
@@ -30,7 +26,7 @@ func TestIconURL(t *testing.T) {
name: "relative",
input: "@target/icon.png",
wantValue: &URL{
FullURL: strPtr("/icon.png"),
FullURL: new("/icon.png"),
Source: SourceRelative,
},
},
@@ -38,7 +34,7 @@ func TestIconURL(t *testing.T) {
name: "relative2",
input: "/icon.png",
wantValue: &URL{
FullURL: strPtr("/icon.png"),
FullURL: new("/icon.png"),
Source: SourceRelative,
},
},

View File

@@ -1,4 +1,4 @@
# qBittorrent Integration Package
# internal/homepage/integrations/qbittorrent
This package provides a qBittorrent widget for the GoDoxy homepage dashboard, enabling real-time monitoring of torrent status and transfer statistics.

View File

@@ -2,6 +2,7 @@ package qbittorrent
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -9,18 +10,29 @@ import (
"github.com/bytedance/sonic"
"github.com/yusing/godoxy/internal/homepage/widgets"
strutils "github.com/yusing/goutils/strings"
)
type Client struct {
URL string
Username string
Password string
Password strutils.Redacted
}
func (c *Client) Initialize(ctx context.Context, url string, cfg map[string]any) error {
c.URL = url
c.Username = cfg["username"].(string)
c.Password = cfg["password"].(string)
username, ok := cfg["username"].(string)
if !ok {
return errors.New("username is not a string")
}
c.Username = username
password, ok := cfg["password"].(string)
if !ok {
return errors.New("password is not a string")
}
c.Password = strutils.Redacted(password)
_, err := c.Version(ctx)
if err != nil {
@@ -37,7 +49,7 @@ func (c *Client) doRequest(ctx context.Context, method, endpoint string, query u
}
if c.Username != "" && c.Password != "" {
req.SetBasicAuth(c.Username, c.Password)
req.SetBasicAuth(c.Username, c.Password.String())
}
resp, err := widgets.HTTPClient.Do(req)

View File

@@ -1,13 +0,0 @@
# Types Package
Configuration types for the homepage package.
## Config
```go
type Config struct {
UseDefaultCategories bool `json:"use_default_categories"`
}
var ActiveConfig atomic.Pointer[Config]
```

View File

@@ -1,4 +1,4 @@
# Homepage Widgets Package
# internal/homepage/widgets
> [!WARNING]
>

View File

@@ -1,4 +1,4 @@
# Idlewatcher
# internal/idlewatcher
Manages container lifecycle based on idle timeout, automatically stopping/pausing containers and waking them on request.

View File

@@ -62,6 +62,6 @@ func DebugHandler(rw http.ResponseWriter, r *http.Request) {
}
}
default:
w.writeLoadingPage(rw)
_ = w.writeLoadingPage(rw)
}
}

View File

@@ -1,4 +1,4 @@
# Idlewatcher Provider
# internal/idlewatcher/provider
Implements container runtime abstractions for Docker and Proxmox LXC backends.

View File

@@ -32,6 +32,8 @@ import (
)
type (
Config = types.IdlewatcherConfig
routeHelper struct {
route types.Route
rp *reverseproxy.ReverseProxy
@@ -52,7 +54,7 @@ type (
l zerolog.Logger
cfg *types.IdlewatcherConfig
cfg *Config
provider synk.Value[idlewatcher.Provider]
@@ -104,7 +106,7 @@ const reqTimeout = 3 * time.Second
// prevents dependencies from being stopped automatically.
const neverTick = time.Duration(1<<63 - 1)
func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig) (*Watcher, error) {
func NewWatcher(parent task.Parent, r types.Route, cfg *Config) (*Watcher, error) {
key := cfg.Key()
watcherMapMu.RLock()
@@ -193,7 +195,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
depCfg := depRoute.IdlewatcherConfig()
if depCfg == nil {
depCfg = new(types.IdlewatcherConfig)
depCfg = new(Config)
depCfg.IdlewatcherConfigBase = cfg.IdlewatcherConfigBase
depCfg.IdleTimeout = neverTick // disable auto sleep for dependencies
} else if depCfg.IdleTimeout > 0 && depCfg.IdleTimeout != neverTick {

View File

@@ -1,4 +1,4 @@
# JSON Store
# internal/jsonstore
The jsonstore package provides persistent JSON storage with namespace support, using thread-safe concurrent maps and automatic loading/saving.

View File

@@ -1,4 +1,4 @@
# Logging Package
# internal/logging
Structured logging capabilities for GoDoxy, including application logging, HTTP access logging, and in-memory log streaming.

View File

@@ -1,4 +1,4 @@
# Access Logging
# internal/logging/accesslog
Provides HTTP access logging with file rotation, log filtering, and multiple output formats for request and ACL event logging.

View File

@@ -17,16 +17,19 @@ type (
} // @name AccessLoggerConfigBase
ACLLoggerConfig struct {
ConfigBase
LogAllowed bool `json:"log_allowed"`
} // @name ACLLoggerConfig
RequestLoggerConfig struct {
ConfigBase
Format Format `json:"format" validate:"oneof=common combined json"`
Filters Filters `json:"filters"`
Fields Fields `json:"fields"`
} // @name RequestLoggerConfig
Config struct {
ConfigBase
acl *ACLLoggerConfig
req *RequestLoggerConfig
}

View File

@@ -21,7 +21,8 @@ var stdoutLogger = func() *zerolog.Logger {
w.FieldsOrder = []string{
"uri", "protocol", "type", "size",
"useragent", "query", "headers", "cookies",
"error", "iso_code", "time_zone"}
"error", "iso_code", "time_zone",
}
})).With().Str("level", zerolog.InfoLevel.String()).Timestamp().Logger()
return &l
}()

View File

@@ -27,6 +27,9 @@ type (
}
fileAccessLogger struct {
RequestFormatter
ACLLogFormatter
task *task.Task
cfg *Config
@@ -41,9 +44,6 @@ type (
errRateLimiter *rate.Limiter
logger zerolog.Logger
RequestFormatter
ACLLogFormatter
}
)
@@ -63,8 +63,10 @@ const (
errBurst = 5
)
var bytesPool = synk.GetUnsizedBytesPool()
var sizedPool = synk.GetSizedBytesPool()
var (
bytesPool = synk.GetUnsizedBytesPool()
sizedPool = synk.GetSizedBytesPool()
)
func NewFileAccessLogger(parent task.Parent, file File, anyCfg AnyConfig) AccessLogger {
cfg := anyCfg.ToConfig()

View File

@@ -8,7 +8,6 @@ import (
"strings"
nettypes "github.com/yusing/godoxy/internal/net/types"
strutils "github.com/yusing/goutils/strings"
)
type (
@@ -54,7 +53,7 @@ func (method HTTPMethod) Fulfill(req *http.Request, res *http.Response) bool {
// Parse implements strutils.Parser.
func (k *HTTPHeader) Parse(v string) error {
split := strutils.SplitRune(v, '=')
split := strings.Split(v, "=")
switch len(split) {
case 1:
split = append(split, "")

View File

@@ -36,9 +36,9 @@ func (m *MockFile) Len() int64 {
func (m *MockFile) Content() []byte {
buf := bytes.NewBuffer(nil)
m.Seek(0, io.SeekStart)
_, _ = m.Seek(0, io.SeekStart)
_, _ = buf.ReadFrom(m.File)
m.Seek(0, io.SeekStart)
_, _ = m.Seek(0, io.SeekStart)
return buf.Bytes()
}

View File

@@ -4,14 +4,15 @@ import (
"errors"
"fmt"
"strconv"
"strings"
strutils "github.com/yusing/goutils/strings"
)
type Retention struct {
Days uint64 `json:"days,omitempty"`
Last uint64 `json:"last,omitempty"`
KeepSize uint64 `json:"keep_size,omitempty"`
Days int64 `json:"days,omitempty" validate:"min=0"`
Last int64 `json:"last,omitempty" validate:"min=0"`
KeepSize int64 `json:"keep_size,omitempty" validate:"min=0"`
} // @name LogRetention
var (
@@ -32,15 +33,15 @@ var defaultChunkSize = 32 * kilobyte
//
// Parse implements strutils.Parser.
func (r *Retention) Parse(v string) (err error) {
split := strutils.SplitSpace(v)
split := strings.Fields(v)
if len(split) != 2 {
return fmt.Errorf("%w: %s", ErrInvalidSyntax, v)
}
switch split[0] {
case "last":
r.Last, err = strconv.ParseUint(split[1], 10, 64)
r.Last, err = strconv.ParseInt(split[1], 10, 64)
default: // <N> days|weeks|months
n, err := strconv.ParseUint(split[0], 10, 64)
n, err := strconv.ParseInt(split[0], 10, 64)
if err != nil {
return err
}

View File

@@ -85,7 +85,7 @@ func rotateLogFileByPolicy(file supportRotate, config *Retention, result *Rotate
switch {
case config.Last > 0:
shouldStop = func() bool { return result.NumLinesKeep-result.NumLinesInvalid == int(config.Last) }
shouldStop = func() bool { return int64(result.NumLinesKeep-result.NumLinesInvalid) == config.Last }
// not needed to parse time for last N lines
case config.Days > 0:
cutoff := mockable.TimeNow().AddDate(0, 0, -int(config.Days)+1)
@@ -227,7 +227,7 @@ func rotateLogFileBySize(file supportRotate, config *Retention, result *RotateRe
result.OriginalSize = fileSize
keepSize := int64(config.KeepSize)
keepSize := config.KeepSize
if keepSize >= fileSize {
result.NumBytesKeep = fileSize
return false, nil

View File

@@ -4,9 +4,9 @@ import (
"errors"
"fmt"
"strconv"
"strings"
gperr "github.com/yusing/goutils/errs"
strutils "github.com/yusing/goutils/strings"
)
type StatusCodeRange struct {
@@ -22,7 +22,7 @@ func (r *StatusCodeRange) Includes(code int) bool {
// Parse implements strutils.Parser.
func (r *StatusCodeRange) Parse(v string) error {
split := strutils.SplitRune(v, '-')
split := strings.Split(v, "-")
switch len(split) {
case 1:
start, err := strconv.Atoi(split[0])

View File

@@ -1,4 +1,4 @@
# In-Memory Logger
# internal/logging/memlogger
Provides a thread-safe in-memory circular buffer logger with WebSocket-based real-time streaming for log data.

View File

@@ -1,4 +1,4 @@
# MaxMind
# internal/maxmind
The maxmind package provides MaxMind GeoIP database integration for IP geolocation, including automatic database downloading and updates.

View File

@@ -4,6 +4,7 @@ import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
@@ -99,7 +100,7 @@ func (cfg *MaxMind) LoadMaxMindDB(parent task.Parent) error {
if !valid {
cfg.Logger().Info().Msg("MaxMind DB not found/invalid, downloading...")
if err = cfg.download(); err != nil {
if err = cfg.download(parent.Context()); err != nil {
return fmt.Errorf("%w: %w", ErrDownloadFailure, err)
}
} else {
@@ -128,7 +129,7 @@ func (cfg *MaxMind) scheduleUpdate(parent task.Parent) {
ticker := time.NewTicker(updateInterval)
cfg.loadLastUpdate()
cfg.update()
cfg.update(task.Context())
defer func() {
ticker.Stop()
@@ -143,15 +144,18 @@ func (cfg *MaxMind) scheduleUpdate(parent task.Parent) {
case <-task.Context().Done():
return
case <-ticker.C:
cfg.update()
cfg.update(task.Context())
}
}
}
func (cfg *MaxMind) update() {
func (cfg *MaxMind) update(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, updateTimeout)
defer cancel()
// check for update
cfg.Logger().Info().Msg("checking for MaxMind DB update...")
remoteLastModified, err := cfg.checkLastest()
remoteLastModified, err := cfg.checkLastest(ctx)
if err != nil {
cfg.Logger().Err(err).Msg("failed to check MaxMind DB update")
return
@@ -165,15 +169,15 @@ func (cfg *MaxMind) update() {
Time("latest", remoteLastModified.Local()).
Time("current", cfg.lastUpdate).
Msg("MaxMind DB update available")
if err = cfg.download(); err != nil {
if err = cfg.download(ctx); err != nil {
cfg.Logger().Err(err).Msg("failed to update MaxMind DB")
return
}
cfg.Logger().Info().Msg("MaxMind DB updated")
}
func (cfg *MaxMind) doReq(method string) (*http.Response, error) {
req, err := http.NewRequest(method, cfg.dbURL(), nil)
func (cfg *MaxMind) doReq(ctx context.Context, method string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, cfg.dbURL(), nil)
if err != nil {
return nil, err
}
@@ -185,34 +189,36 @@ func (cfg *MaxMind) doReq(method string) (*http.Response, error) {
return resp, nil
}
func (cfg *MaxMind) checkLastest() (lastModifiedT *time.Time, err error) {
resp, err := cfg.doReq(http.MethodHead)
func (cfg *MaxMind) checkLastest(ctx context.Context) (lastModifiedT time.Time, err error) {
resp, err := cfg.doReq(ctx, http.MethodHead)
if err != nil {
return nil, err
return time.Time{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d", ErrResponseNotOK, resp.StatusCode)
return time.Time{}, fmt.Errorf("%w: %d", ErrResponseNotOK, resp.StatusCode)
}
lastModified := resp.Header.Get("Last-Modified")
if lastModified == "" {
cfg.Logger().Warn().Msg("MaxMind responded no last modified time, update skipped")
return nil, nil
return time.Time{}, nil
}
lastModifiedTime, err := time.Parse(http.TimeFormat, lastModified)
if err != nil {
cfg.Logger().Warn().Err(err).Msg("MaxMind responded invalid last modified time, update skipped")
return nil, err
return time.Time{}, err
}
return &lastModifiedTime, nil
return lastModifiedTime, nil
}
func (cfg *MaxMind) download() error {
resp, err := cfg.doReq(http.MethodGet)
func (cfg *MaxMind) download(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, updateTimeout)
defer cancel()
resp, err := cfg.doReq(ctx, http.MethodGet)
if err != nil {
return err
}

View File

@@ -72,7 +72,7 @@ func mockMaxMindDBOpen(t *testing.T) {
func Test_MaxMindConfig_doReq(t *testing.T) {
cfg := testCfg()
mockDoReq(t, cfg)
resp, err := cfg.doReq(http.MethodGet)
resp, err := cfg.doReq(t.Context(), http.MethodGet)
if err != nil {
t.Fatalf("newReq() error = %v", err)
}
@@ -85,7 +85,7 @@ func Test_MaxMindConfig_checkLatest(t *testing.T) {
cfg := testCfg()
mockDoReq(t, cfg)
latest, err := cfg.checkLastest()
latest, err := cfg.checkLastest(t.Context())
if err != nil {
t.Fatalf("checkLatest() error = %v", err)
}
@@ -100,7 +100,7 @@ func Test_MaxMindConfig_download(t *testing.T) {
mockMaxMindDBOpen(t)
mockDoReq(t, cfg)
err := cfg.download()
err := cfg.download(t.Context())
if err != nil {
t.Fatalf("download() error = %v", err)
}

View File

@@ -1,4 +1,4 @@
# Metrics Package
# internal/metrics
System monitoring and metrics collection for GoDoxy with time-series storage and REST/WebSocket APIs.

View File

@@ -1,4 +1,4 @@
# Period Metrics
# internal/metrics/period
Provides time-bucketed metrics storage with configurable periods, enabling historical data aggregation and real-time streaming.
@@ -453,7 +453,7 @@ for {
- O(1) add to circular buffer
- O(1) get (returns slice view)
- O(n) serialization where n = total entries
- Memory: O(5 * 100 * sizeof(T)) = fixed overhead
- Memory: O(5 _ 100 _ sizeof(T)) = fixed overhead
- JSON load/save: O(n) where n = total entries
## Testing Notes

View File

@@ -33,11 +33,7 @@ func (p *Poller[T, AggregateT]) ServeHTTP(c *gin.Context) {
query := c.Request.URL.Query()
if httpheaders.IsWebsocket(c.Request.Header) {
interval := metricsutils.QueryDuration(query, "interval", 0)
if interval < PollInterval {
interval = PollInterval
}
interval := max(metricsutils.QueryDuration(query, "interval", 0), PollInterval)
websocket.PeriodicWrite(c, interval, func() (any, error) {
return p.GetRespData(period, query)
})

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