Compare commits

..

86 Commits

Author SHA1 Message Date
yusing
52511f1618 Cherry-pick 372132b1da 2026-01-30 00:33:34 +08:00
yusing
ca2cbd3332 chore: upgrade dependencies 2026-01-30 00:32:39 +08:00
yusing
57f7b72923 chore(json): use stdlib json for compatibility 2026-01-30 00:30:48 +08:00
yusing
54b3008cce refactor: propagate context and standardize HTTP client timeouts
Add context parameter to TCP/UDP stream health checks and client constructors
for proper cancellation and deadline propagation. Switch from encoding/json
to sonic for faster JSON unmarshaling.

Standardize HTTP client timeouts to 5 seconds
across agent pool and health check.
2026-01-30 00:28:17 +08:00
yusing
d9abd65471 factor(route): make proxmox validation non-critical
Proxmox validation errors are now logged and ignored rather than
causing route validation to fail, allowing routes to function even
when proxmox integration encounters issues.

- Extract proxmox validation into dedicated validateProxmox() method
- Log warnings/errors instead of returning validation errors
- Add warning when proxmox config exists but no node/resource found
2026-01-30 00:28:00 +08:00
yusing
d5b9d2e6bc fix(serialization): correct validation parameter
- Fix bug in mapUnmarshalValidate where checkValidateTag parameter
  was incorrectly negated when passed to Convert()
- Remove obsolete validateWithValidator helper function
2026-01-30 00:28:00 +08:00
yusing
ec9cab05c7 chore(docs): update package docs for internal/serialization 2026-01-30 00:27:59 +08:00
yusing
6ff1346675 feat(api): add route validation endpoint with WebSocket support
Adds a new `/route/validate` endpoint that accepts YAML-encoded route
configurations for validation. Supports both synchronous HTTP requests
and real-time streaming via WebSocket for interactive validation workflows.

Changes:
- Implement Validate handler with YAML binding in route/validate.go
- Add WebSocket manager for streaming validation results
- Register GET/POST routes in handler.go
- Regenerate Swagger documentation
2026-01-30 00:27:58 +08:00
yusing
a75441aa8a refactor(serialization): generalize unmarshal/load functions with pluggable format handlers
Replace YAML-specific functions with generic ones accepting unmarshaler/marshaler
function parameters. This enables future support for JSON and other formats
while maintaining current YAML behavior.

- UnmarshalValidateYAML -> UnmarshalValidate(unmarshalFunc)
- UnmarshalValidateYAMLXSync -> UnmarshalValidateXSync(unmarshalFunc)
- SaveJSON -> SaveFile(marshalFunc)
- LoadJSONIfExist -> LoadFileIfExist(unmarshalFunc)
- Add UnmarshalValidateReader for reader-based decoding

Testing: all 12 staged test files updated to use new API
2026-01-30 00:27:38 +08:00
yusing
32971b45c3 chore(swagger): update API documentation annotations
- Change ValidateFile endpoint Accept type from text/plain to json
- Add Route struct name annotation for Swagger documentation
2026-01-30 00:27:07 +08:00
yusing
4bc7ce09c7 chore(docs): update package docs for internal/proxmox 2026-01-30 00:27:06 +08:00
yusing
b5946b34b8 refactor(proxmox): add struct level validation for node configuration services and files
Add Validate() method to NodeConfig that implements the CustomValidator
interface. The method checks all services and files for invalid shell
metacharacters (&, $(), etc.) to prevent shell injection attacks.

Testing: Added validation_test.go with 6 table-driven test cases covering
valid inputs and various shell metacharacter injection attempts.
2026-01-30 00:26:59 +08:00
yusing
442e4a0972 fix(proxmox): improve journalctl with log tailing fallback for non-systemd systems
- Format tail command with fallback retry logic
- Add /var/log/messages fallback when no services specified

Improves log viewing reliability on systems without systemd support.
2026-01-30 00:26:28 +08:00
yusing
d1c79acf87 fix(docker): improve error handling for missing Docker agent
Replaced panic with an error return in the NewClient
2026-01-28 17:23:53 +08:00
yusing
df70f7f63c refactor(proxmox): add validation for node name and VMID in provider initialization 2026-01-28 17:23:52 +08:00
yusing
6707143aaf refactor(logging): add non-blocking writer for high-volume logging
Replace synchronous log writing with zerolog's diode-based non-blocking
writer to prevent logging from blocking the main application during
log bursts. The diode writer buffers up to 1024 messages and logs a
warning when messages are dropped.

- Extract multi-writer logic into separate `multiWriter` function
- Wrap with `diode.NewWriter` for async buffering
- Update both `NewLogger` and `NewLoggerWithFixedLevel` to use diode
2026-01-28 17:23:52 +08:00
yusing
742a86d079 refactor(config): simplify route provider loading with improved error handling
Streamlined the `loadRouteProviders()` function by:
- Replacing channel-based concurrency with a simpler sequential registration pattern after agent initialization
- Using `gperr.NewGroup` and `gperr.NewBuilder` for more idiomatic error handling
- Adding mutex protection for concurrent result building
- Removing the `storeProvider` helper method
2026-01-28 17:23:51 +08:00
yusing
45f856b8d1 chore(config): make initialization timeout configurable via environment variable
Replaced hardcoded 10-second initialization timeout with a configurable `INIT_TIMEOUT` environment variable.
The new default is 1 minute, allowing operators to adjust startup behavior based on their infrastructure requirements.
2026-01-28 17:23:51 +08:00
yusing
044c7bf8a0 feat(proxmox): add session refresh loop to maintain Proxmox API session
Introduced a new session refresh mechanism in the Proxmox configuration to ensure the API session remains active. This includes:
- Added `SessionRefreshInterval` constant for configurable session refresh timing.
- Implemented `refreshSessionLoop` method to periodically refresh the session and handle errors with exponential backoff.

This enhancement improves the reliability of interactions with the Proxmox API by preventing session expiry.
2026-01-28 17:23:50 +08:00
yusing
e7b19c472b feat(proxmox): add tail endpoint and enhance journalctl with multi-service support
Add new `/proxmox/tail` API endpoint for streaming file contents from Proxmox
nodes and LXC containers via WebSocket. Extend journalctl endpoint to support
filtering by multiple services simultaneously.

Changes:
- Add `GET /proxmox/tail` endpoint supporting node-level and LXC container file tailing
- Change `service` parameter from string to array in journalctl endpoints
- Add input validation (`checkValidInput`) to prevent command injection
- Refactor command formatting with proper shell quoting

Security: All command inputs are validated for dangerous characters before
2026-01-28 17:23:45 +08:00
yusing
1ace5e641d feat(proxmox): better node-level routes auto-discovery with pointer VMID
- Add BaseURL field to Client for node-level route configuration
- Change VMID from int to *int to support three states:
  - nil: auto-discover node or VM from hostname/IP/alias
  - 0: node-level route (direct to Proxmox node API)
  - >0: LXC/QEMU resource route with container control
- Change Service string to Services []string for multi-service support
- Implement proper node-level route handling: HTTPS scheme,
  hostname from node BaseURL, default port 8006
- Move initial UpdateResources call to Init before starting loop
- Move proxmox auto-discovery earlier in route validation

BREAKING: NodeConfig.VMID is now a pointer type; NodeConfig.Service
renamed to Services (backward compatible via alias)
2026-01-28 17:23:29 +08:00
yusing
83976646db feat(api): support query parameters for proxmox journalctl endpoint
Refactored the journalctl API to accept `node`, `vmid`, and `service` parameters as query strings in addition to path parameters. Added a new route `/proxmox/journalctl` that accepts all parameters via query string while maintaining backward compatibility with existing path-parameter routes.

- Changed `JournalctlRequest` struct binding from URI-only to query+URI
- Simplified Swagger documentation by consolidating multiple route definitions
- Existing path-parameter routes remain functional for backward compatibility
2026-01-28 17:23:29 +08:00
yusing
8e1d75abd6 refactor(swagger): rename DockerConfig and ProxmoxNodeConfig to IdlewatcherDockerConfig and IdlewatcherProxmoxNodeConfig 2026-01-28 17:23:28 +08:00
yusing
5c341d4745 refactor: improve error handling, validation and proper cleanup 2026-01-28 17:23:28 +08:00
yusing
9f245a62f2 fix(proxmox): concurrent map write in UpdateResources 2026-01-25 18:05:36 +08:00
yusing
ecc2547eb1 chore: go mod tidy 2026-01-25 17:41:10 +08:00
yusing
e2a48fcff9 fix(api/docker): correct paramaters 2026-01-25 17:40:47 +08:00
yusing
6b09f54793 chore: upgrade dependencies 2026-01-25 17:31:29 +08:00
yusing
7b5ad3ab5e chore(docker): add go-proxmox module dependencies to Dockerfile 2026-01-25 17:30:23 +08:00
yusing
48790b4756 refactor(types): decouple Proxmox config from proxmox package
Decouple the types package from the internal/proxmox package by defining
a standalone ProxmoxConfig struct. This reduces circular dependencies
and allows the types package to define its own configuration structures
without importing the proxmox package.

The route validation logic now converts between types.ProxmoxConfig and
proxmox.NodeConfig where needed for internal operations.
2026-01-25 17:29:43 +08:00
yusing
727a3e9452 chore(docs): enhance README with Proxmox integration details
Added sections for Proxmox integration, including automatic route binding, WebUI management, and API endpoints. Updated existing content to reflect LXC lifecycle control and real-time logging capabilities for both Docker and Proxmox environments.
2026-01-25 17:29:42 +08:00
yusing
f8f1b54aaf fix(scripts/update-wiki): add "internal/go-proxmox/" to skipSubmodules list 2026-01-25 17:29:42 +08:00
yusing
cb8f405e76 feat(proxmox): add node-level stats endpoint with streaming support
Add new `/proxmox/stats/{node}` API endpoint for retrieving Proxmox node
statistics in JSON format. The endpoint returns kernel version, CPU
usage/model, memory usage, rootfs usage, uptime, and load averages.

The existing `/proxmox/stats/{node}/{vmid}` endpoint has been corrected `VMStats` to return`text/plain` instead of `application/json`.

Both endpoints support WebSocket streaming for real-time stats updates
with a 1-second poll interval.
2026-01-25 17:29:41 +08:00
yusing
51704829c6 refactor(proxmox): move NodeCommand to node_command.go 2026-01-25 17:29:36 +08:00
yusing
a1cc1d844d chore(docs): update package docs for proxmox and route/routes 2026-01-25 17:29:01 +08:00
yusing
633deb85ca feat(proxmox): support node-level routes and journalctl access
This change enables Proxmox node-level operations without requiring a specific
LXC container VMID.

**Features added:**
- New `/proxmox/journalctl/{node}` API endpoint for streaming node journalctl
- Route configuration support for Proxmox nodes (VMID = 0)
- `ReverseLookupNode` function for node discovery by hostname/IP/alias
- `NodeJournalctl` method for executing journalctl on nodes

**Behavior changes:**
- VMID parameter in journalctl endpoints is now optional
- Routes targeting nodes (without specific containers) are now valid

**Bug fixes:**
- Fixed error message variable reference in route validation
2026-01-25 17:29:00 +08:00
yusing
ee1c375fd9 refactor(proxmox): extract websocket command execution into reusable NodeCommand method
The LXCCommand method contained duplicate websocket handling logic for connecting to Proxmox's VNC terminal proxy. This refactoring extracts the common websocket connection, streaming, and cleanup logic into a new NodeCommand method on the Node type, allowing LXCCommand to simply format the pct command and delegate.

The go-proxmox submodule was also updated to access the NewNode constructor, which provides a cleaner API for creating node instances with the HTTP client.

- Moves ~100 lines of websocket handling from lxc_command.go to node.go
- Adds reusable NodeCommand method for executing commands via VNC websocket
- LXCCommand now simply calls NodeCommand with formatted command
- Maintains identical behavior and output streaming semantics
2026-01-25 17:28:58 +08:00
yusing
2713f5282e fix(proxmox): prevent goroutine leaks by closing idle HTTP connections
Added a function to close idle HTTP connections in the LXCCommand method. This addresses potential goroutine leaks caused by the go-proxmox library's TermWebSocket not closing underlying HTTP/2 connections. The websocket closer is now wrapped to ensure proper cleanup of transport connections when the command execution is finished.
2026-01-25 17:28:17 +08:00
yusing
f70cca0d00 fix(swagger): remove /api/v1 prefix from Proxmox endpoints
Streamline Proxmox API route paths by removing incorrect /api/v1 prefix.

Changed endpoints:
- /api/v1/proxmox/journalctl/{node}/{vmid} → /proxmox/journalctl/{node}/{vmid}
- /api/v1/proxmox/journalctl/{node}/{vmid}/{service} → /proxmox/journalctl/{node}/{vmid}/{service}
- /api/v1/proxmox/lxc/:node/:vmid/restart → /proxmox/lxc/:node/:vmid/restart
- /api/v1/proxmox/lxc/:node/:vmid/start → /proxmox/lxc/:node/:vmid/start
- /api/v1/proxmox/lxc/:node/:vmid/stop → /proxmox/lxc/:node/:vmid/stop
- /api/v1/proxmox/stats/{node}/{vmid} → /proxmox/stats/{node}/{vmid}

Updated:
- Swagger annotations in 5 Go source files
- Generated swagger.json and swagger.yaml documentation
2026-01-25 17:28:16 +08:00
yusing
3adefe4202 fix: add startup timeout guard to prevent indefinite hangs
Add a 10-second timeout mechanism during application initialization. If initialization
fails to complete within the timeout window, the application logs a fatal error and exits.
This prevents the proxy from becoming unresponsive during startup due to blocking operations
in parallel initialization tasks (DNS providers, icon cache, system info poller, middleware
loading, Docker client, API server, debug server, config watcher).

The timeout guard uses a background goroutine that listens for either a completion signal
(via closing the done channel) or the timeout expiration, providing a safety net for
long-running or blocked initialization scenarios.
2026-01-25 17:28:15 +08:00
yusing
966c873013 feat(proxmox): add LXC container control endpoints
Add start, stop, and restart endpoints for LXC containers via the Proxmox API:
- POST /api/v1/proxmox/lxc/:node/:vmid/start
- POST /api/v1/proxmox/lxc/:node/:vmid/stop
- POST /api/v1/proxmox/lxc/:node/:vmid/restart
2026-01-25 17:28:15 +08:00
yusing
b8c61c37dc feat(proxmox): add journalctl endpoint without service; add limit parameter
Added new Proxmox journalctl endpoint `/journalctl/:node/:vmid` for viewing all
journalctl output without requiring a service name. Made the service parameter
optional across both endpoints.

Introduced configurable `limit` query parameter (1-1000, default 100) to both
proxmox journalctl and docker logs APIs, replacing hardcoded 100-line tail.

Added container status check in LXCCommand to prevent command execution on
stopped containers, returning a clear status message instead.

Refactored route validation to use pre-fetched IPs and improved References()
method for proxmox routes with better alias handling.
2026-01-25 17:28:14 +08:00
yusing
22582cd32f chore(docs): update proxmox package docs 2026-01-25 17:28:14 +08:00
yusing
a46573cab3 feat(proxmox): enhance VM resource tracking with auto-discovery and cached IPs
- Add VMResource wrapper type with cached IP addresses for efficient lookups
- Implement concurrent IP fetching during resource updates (limited concurrency)
- Add ReverseLookupResource for discovering VMs by IP, hostname, or alias
- Prioritize interfaces API over config for IP retrieval (offline container fallback)
- Enable routes to auto-discover Proxmox resources when no explicit config provided
- Fix configuration type from value to pointer slice for correct proxmox client retrievel
- Ensure Proxmox providers are initialized before route validation
2026-01-25 17:28:13 +08:00
yusing
64e380cc40 feat(proxmox): add LXC container stats endpoint with streaming support
Implement a new API endpoint to retrieve real-time statistics for Proxmox
LXC containers, similar to `docker stats` functionality.

Changes:
- Add `GET /api/v1/proxmox/stats/:node/:vmid` endpoint with HTTP and WebSocket support
- Implement resource polling loop to cache VM metadata every 3 seconds
- Create `LXCStats()` method with streaming (websocket) and single-shot modes
- Format output as: STATUS|CPU%|MEM USAGE/LIMIT|MEM%|NET I/O|BLOCK I/O
- Add `GetResource()` method for efficient VM resource lookup by kind and ID
- Fix task creation bug using correct client reference

Example response:
  running|31.1%|9.6GiB/20GiB|48.87%|4.7GiB/3.3GiB|25GiB/36GiB
2026-01-25 17:28:05 +08:00
yusing
2f68c7c386 fix(proxmox): enhance LXCCommand skip logic
Updated the LXCCommand function to skip until`\x1b[H` and `\x1b[?2004`, ensuring no garbage output.
2026-01-25 17:26:47 +08:00
yusing
e3001a70ed refactor(proxmox): consolidate NodeConfig and add service field
Centralize Proxmox node configuration by moving `ProxmoxConfig` from `internal/types/idlewatcher.go` to a new `NodeConfig` struct in `internal/proxmox/node.go`.

- Add `proxmox` field to route; allowing `proxy.app.proxmox` labels and corresponding route file config
- Added `service` optional field to NodeConfig for service identification
- Integrated Proxmox config directly into Route struct with proper validation
- Propagate Proxmox settings to Idlewatcher during route validation
- Updated swagger documentation to reflect schema changes
2026-01-25 17:26:46 +08:00
yusing
ea6543b3f9 feat(proxmox): add journalctl streaming API endpoint for LXC containers
Add new /api/v1/proxmox/journalctl/:node/:vmid/:service endpoint that
streams real-time journalctl output from Proxmox LXC containers via
WebSocket connection. This enables live monitoring of container services
from the GoDoxy WebUI.

Implementation includes:
- New proxmox API handler with path parameter validation
- WebSocket upgrade for streaming output
- LXCCommand helper for executing commands over Proxmox VNC websocket
- LXCJournalctl wrapper for convenient journalctl -u service -f invocation
- Updated API documentation with proxmox integration
2026-01-25 17:26:46 +08:00
yusing
1793dd629f Requires authenticated Proxmox session with username/password configured.
refactor(proxmox): support for PAM authentication

- Added support for username and password authentication alongside existing token-based authentication.
- Updated validation rules to require either token or username/password for authentication.
- Modified the Init function to handle session creation based on the selected authentication method.
- Increased timeout duration for context in the Init function.
2026-01-25 17:26:45 +08:00
yusing
edf8c6ea32 feat(proxmox): add go-proxmox submodule for customized Proxmox integration
Add the go-proxmox library as a Git submodule to enable Proxmox
integration for container/VM management.

Submodule: https://github.com/yusing/go-proxmox
2026-01-25 17:26:45 +08:00
yusing
60cdffcf3c refactor(metrics): remove unused fields from RouteAggregate and update related documentation
- Removed `display_name`, `is_docker`, and `is_excluded` fields from the `RouteAggregate` struct and corresponding Swagger documentation.
- Updated references in the README and code to reflect the removal of these fields, ensuring consistency across the codebase.
2026-01-25 17:26:44 +08:00
yusing
717ed04b38 refactor(query): remove SearchRoute function and related documentation 2026-01-25 17:26:43 +08:00
yusing
80a6b21ff9 refactor(routes): replace route retrieval with GetIncludeExcluded
- Updated route retrieval in the API and idle watcher to use GetIncludeExcluded, allowing for the inclusion of excluded routes.
- Simplified the route status aggregation logic by directly using GetIncludeExcluded for display name resolution.
- Removed redundant code that separately handled excluded routes, streamlining the route management process.
2026-01-25 17:26:43 +08:00
yusing
e48c3f57dd feat(api): add endpoint to retrieve container stats
- Introduced a new GET endpoint `/docker/stats/:id` to fetch statistics for a specified container by its ID or route alias.
- Implemented the `Stats` function in the `dockerapi` package to handle the request and return container stats in both JSON and WebSocket formats.
- Added error handling for invalid requests and container not found scenarios.
2026-01-25 17:26:42 +08:00
yusing
39f427555f feat(ci): separate cache for different tags; utilize gha cache 2026-01-22 16:23:12 +08:00
yusing
280e455198 feat(ci): pass BRANCH to Makefile for correct build tag 2026-01-22 16:16:03 +08:00
yusing
082991694b feat(ci): enhance Docker image workflow to compute version based on Git tags and branches
- Added a step to checkout the repository for accurate tag resolution.
- Implemented logic to determine the build version based on the Git reference type, supporting tags and branch names.
- Updated the Docker build arguments to use the computed version for better versioning in images.
2026-01-22 16:12:01 +08:00
yusing
4ec9d818bf fix(Makefile): no longer add sonic tag to compat build 2026-01-22 16:11:46 +08:00
yusing
258d8921ad chore(deps): upgrade dependencies 2026-01-22 15:41:01 +08:00
yusing
2b8d416625 refactor(watcher): simplify config file watcher initialization using sync.Once 2026-01-22 15:36:48 +08:00
yusing
b4e9613efe refactor(memlogger): remove HTTP/WebSocket handler and simplify buffer management
Removes the embedded HTTP handler and WebSocket streaming capability from the
in-memory logger, leaving only the core io.Writer interface and event subscription
via Events(). Simplifies buffer management by eliminating position-based tracking
and using slices.Clone() for safe message passing to listeners.

- Removes HandlerFunc(), ServeHTTP(), wsInitial(), wsStreamLog() methods
- Removes logEntryRange struct and connChans map (no longer needed)
- Refactors buffer field from embedded to explicit buf with named mutexes
- Adds buffered channel (64) for event listeners to prevent blocking
- Improves concurrency with double-checked locking in truncation logic
2026-01-22 15:36:48 +08:00
yusing
25605208e4 fix(config): update JSON tags in ACL and access log configurations to omit empty values
Modified JSON tags in the Notify struct of ACL config and the ConfigBase and Retention structs in access log config to include 'omitempty'
2026-01-22 15:36:47 +08:00
yusing
29036bed18 fix(loadbalancer): change pool type from value to pointer 2026-01-22 15:36:47 +08:00
yusing
6d78f14cc7 fix(logging): update JSON tags in access log configuration to omit zero values
Modified JSON tags in the Filters and Fields structs to include 'omitzero', ensuring that zero values are not included in the serialized output.
2026-01-22 15:36:46 +08:00
yusing
4730753cb3 fix(logging): correct variable shadowing in NewLoggerWithFixedLevel causing incorrect log level being assigned 2026-01-22 15:36:46 +08:00
yusing
4fa2ef41aa feat(pool): introduce tombstone-based deletion with soft-delete mechanism
Refactored the pool implementation to use a tombstone-based deletion strategy
instead of immediate removal. This allows correct logging "reload"
instead of "removed" + "added" when an item is quickly deleted
and re-added within a short time window.

Changes:
- Items are now marked as tombstones upon deletion and retained for 1 second
- Added `PurgeExpiredTombs()` method for cleanup of expired tombstones
- Updated `Get`, `Iter`, and `Slice` to skip tombstoned entries
- Updated `Del` and `DelKey` to cleanup tombstones when exceeding threshold
- `AddIfNotExists` can now "reload" recently deleted items within the TTL
- Added tomb counter for tracking active tombstones and triggering purge
2026-01-22 15:36:45 +08:00
yusing
732376328c fix(acl): correctly marshal matchers instead of plain '{}'
- Introduced a raw field in the Matcher struct to store the original string representation.
- Implemented MarshalText method for Matcher
2026-01-22 15:36:45 +08:00
yusing
110fe4b0aa feat(api): enhance API handler to support unauthenticated local access
- Updated NewHandler function to accept a requireAuth parameter for authentication control.
- Introduced a new local API server that allows unauthenticated access when LocalAPIHTTPAddr is set.
- Adjusted server startup logic to handle both authenticated and unauthenticated API routes.
2026-01-22 15:36:36 +08:00
yusing
45ba8d447a fix(config): no longer show "http_route: added <route>" on startup 2026-01-21 14:32:28 +00:00
yusing
108417ca9d fix(websocket): log errors only for non-normal closure codes 2026-01-21 14:32:28 +00:00
yusing
bd1ff9731d refactor(accesslog): restructure access logging; enhance console output format
Major refactoring of the access logging infrastructure to improve code organization and add proper console/stdout logging support.

- Renamed `Writer` interface to `File` and consolidated with `SupportRotate`
- Renamed `Log(req, res)` to `LogRequest(req, res)` for clarity
- Added new `ConsoleLogger` with zerolog console writer for formatted stdout output
- Moved type definitions to new `types.go` file
- Changed buffer handling from `[]byte` returns to `*bytes.Buffer` parameters
- Renamed internal files for clarity (`access_logger.go` → `file_access_logger.go`)
- Fixed fileserver access logging timing: moved logging after handler execution with defer
- Correct response handling in Fileserver
- Remove deprecated field `buffer_size`
- Simplify and removed unnecessary code

All callers have been updated to use the new APIs.
2026-01-21 14:32:28 +00:00
yusing
235af71343 perf(accesslog): use buffer pool in BackScanner to reduce allocations
Replace per-scan byte slice allocations with a sized buffer pool,
significantly reducing memory pressure during log file scanning.

- Add Release() method to return buffers to pool (callers must invoke)
- Remove Reset() method - create new scanner instead for simpler lifecycle
- Refactor chunk prepending to reuse pooled buffers instead of append

Benchmark results show allocations dropped from ~26k to 1 per scan
for small chunk sizes, with better throughput.

BREAKING CHANGE: Reset() removed; callers must call Release() and
create a new BackScanner instance instead.
2026-01-21 14:32:28 +00:00
yusing
059306c93b fix(config): rename initAccessLogger to initACL 2026-01-21 14:32:28 +00:00
yusing
d938e24cf5 fix(acl): ensure acl behind proxy protocol for TCP; fix acl not working for TCP/UDP by replacing ActiveConfig with context value 2026-01-21 14:32:28 +00:00
yusing
ab1881d02e fix(acl): deny rules now have higher precedence than allow rules 2026-01-21 14:32:28 +00:00
FrozenFrog
90a4922b79 feat(middleware): implement CrowdSec WAF bouncer middleware (#196)
* crowdsec middleware
2026-01-21 14:32:28 +00:00
yusing
2022a0db82 fix(docker): correct 89cbcfee8c 2026-01-18 00:53:51 +08:00
yusing
4f8bb40d3d chore(docs): update package docs for internal/homepage 2026-01-17 16:16:24 +08:00
yusing
8ea296c99f fix(config): replace ToggleLog with DisableLog for clearer intent in loadRouteProviders 2026-01-17 16:16:16 +08:00
yusing
ccefaf003d fix(route): correct URL construction for IPv6 host 2026-01-17 16:16:08 +08:00
yusing
61236e0ace fix(docker): add container name to network not found error 2026-01-17 16:16:03 +08:00
yusing
6a89ab77c8 fix(docker): fix incorrect network not found error 2026-01-17 16:15:56 +08:00
yusing
9f1c279698 fix(idlewatcher): remove duplicated w.readyNotifyCh notification 2026-01-17 16:14:44 +08:00
yusing
89cbcfee8c fix(docker): add back client.WithAPIVersionNegotiation() to ensure docker compatibility 2026-01-17 15:27:49 +08:00
Charles GTE
2da4bbedbf fix(script): correct sed command in setup.sh for macos (#194)
use `uname -s` for OS detection

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: yusing <yusing.wys@gmail.com>
2026-01-17 15:26:37 +08:00
yusing
d73272b8e0 Rebased from main: Old CPU / Old docker version compatibility 2026-01-16 21:27:37 +08:00
107 changed files with 2827 additions and 1119 deletions

View File

@@ -0,0 +1,20 @@
name: Docker Image CI (compat)
on:
push:
branches:
- compat
jobs:
build-compat:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/godoxy
tag: latest-compat
target: main
build-compat-agent:
uses: ./.github/workflows/docker-image.yml
with:
image_name: ${{ github.repository_owner }}/godoxy-agent
tag: latest-compat
target: agent

View File

@@ -8,6 +8,7 @@ on:
- "**" # matches every branch
- "!dependabot/*"
- "!main" # excludes main
- "!compat" # excludes compat branch
jobs:
build-nightly:

6
.gitignore vendored
View File

@@ -40,4 +40,8 @@ tsconfig.tsbuildinfo
!agent.compose.yml
!agent/pkg/**
dev-data/
dev-data/
RELEASE_NOTES.md
CLAUDE.md
.kilocode/**

View File

@@ -14,15 +14,16 @@ replace (
exclude github.com/containerd/nerdctl/mod/tigron v0.0.0
exclude github.com/yusing/godoxy/internal/utils v0.0.0-20250927032450-e2aeef3a863f
require (
github.com/bytedance/sonic v1.15.0
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
github.com/pion/dtls/v3 v3.0.10
github.com/pion/transport/v3 v3.1.1
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
github.com/yusing/godoxy v0.25.0
github.com/yusing/godoxy v0.25.2
github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000
github.com/yusing/goutils v0.7.0
)
@@ -31,6 +32,7 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@@ -38,7 +40,8 @@ require (
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v29.1.5+incompatible // indirect
github.com/docker/cli v29.2.0+incompatible // indirect
github.com/docker/docker v28.5.2+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
@@ -62,8 +65,9 @@ require (
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
github.com/moby/moby/api v1.52.0 // indirect
github.com/moby/moby/client v0.2.1 // indirect
github.com/moby/moby/api v1.53.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -71,6 +75,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/puzpuzpuz/xsync/v4 v4.4.0 // indirect
@@ -86,12 +91,13 @@ require (
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/yusing/ds v0.4.1 // indirect
github.com/yusing/gointernals v0.1.16 // indirect
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260125040745-bcc4b498f878 // indirect
github.com/yusing/goutils/http/websocket v0.0.0-20260125040745-bcc4b498f878 // indirect
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260129081554-24e52ede7468 // indirect
github.com/yusing/goutils/http/websocket v0.0.0-20260129081554-24e52ede7468 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/arch v0.23.0 // indirect
@@ -99,6 +105,7 @@ require (
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,3 +1,5 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
@@ -24,6 +26,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@@ -37,8 +41,10 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v29.1.5+incompatible h1:GckbANUt3j+lsnQ6eCcQd70mNSOismSHWt8vk2AX8ao=
github.com/docker/cli v29.1.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -82,8 +88,8 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -95,6 +101,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
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/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
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=
@@ -128,15 +136,21 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.2.1 h1:1Grh1552mvv6i+sYOdY+xKKVTvzJegcVMhuXocyDz/k=
github.com/moby/moby/client v0.2.1/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE=
github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -153,8 +167,9 @@ github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkY
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pires/go-proxyproto v0.9.1 h1:wTPjpyk41pJm1Im9BqHtPLuhxfjxL+qNfSikx9ux0WY=
github.com/pires/go-proxyproto v0.9.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U=
github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
@@ -220,6 +235,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGN
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
@@ -228,6 +247,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
@@ -258,6 +279,12 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -268,5 +295,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

View File

@@ -150,7 +150,7 @@ func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte)
// test stream server connection
const fakeAddress = "localhost:8080" // it won't be used, just for testing
// test TCP stream support
err := agentstream.TCPHealthCheck(cfg.Addr, cfg.caCert, cfg.clientCert)
err := agentstream.TCPHealthCheck(ctx, cfg.Addr, cfg.caCert, cfg.clientCert)
if err != nil {
streamUnsupportedErrs.Addf("failed to connect to stream server via TCP: %w", err)
} else {
@@ -158,7 +158,7 @@ func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte)
}
// test UDP stream support
err = agentstream.UDPHealthCheck(cfg.Addr, cfg.caCert, cfg.clientCert)
err = agentstream.UDPHealthCheck(ctx, cfg.Addr, cfg.caCert, cfg.clientCert)
if err != nil {
streamUnsupportedErrs.Addf("failed to connect to stream server via UDP: %w", err)
} else {
@@ -313,8 +313,18 @@ func (cfg *AgentConfig) do(ctx context.Context, method, endpoint string, body io
if err != nil {
return nil, err
}
timeout := 5 * time.Second
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > 0 {
timeout = remaining
}
}
client := http.Client{
Transport: cfg.Transport(),
Timeout: timeout,
}
return client.Do(req)
}

View File

@@ -1,6 +1,7 @@
package stream
import (
"context"
"crypto/tls"
"crypto/x509"
"net"
@@ -34,13 +35,13 @@ func NewTCPClient(serverAddr, targetAddress string, caCert *x509.Certificate, cl
return nil, err
}
return newTCPClientWIthHeader(serverAddr, header, caCert, clientCert)
return newTCPClientWIthHeader(context.Background(), serverAddr, header, caCert, clientCert)
}
func TCPHealthCheck(serverAddr string, caCert *x509.Certificate, clientCert *tls.Certificate) error {
func TCPHealthCheck(ctx context.Context, serverAddr string, caCert *x509.Certificate, clientCert *tls.Certificate) error {
header := NewStreamHealthCheckHeader()
conn, err := newTCPClientWIthHeader(serverAddr, header, caCert, clientCert)
conn, err := newTCPClientWIthHeader(ctx, serverAddr, header, caCert, clientCert)
if err != nil {
return err
}
@@ -49,7 +50,7 @@ func TCPHealthCheck(serverAddr string, caCert *x509.Certificate, clientCert *tls
return nil
}
func newTCPClientWIthHeader(serverAddr string, header *StreamRequestHeader, caCert *x509.Certificate, clientCert *tls.Certificate) (net.Conn, error) {
func newTCPClientWIthHeader(ctx context.Context, serverAddr string, header *StreamRequestHeader, caCert *x509.Certificate, clientCert *tls.Certificate) (net.Conn, error) {
// Setup TLS configuration
caCertPool := x509.NewCertPool()
caCertPool.AddCert(caCert)
@@ -62,17 +63,43 @@ func newTCPClientWIthHeader(serverAddr string, header *StreamRequestHeader, caCe
ServerName: common.CertsDNSName,
}
dialer := &net.Dialer{
Timeout: dialTimeout,
}
tlsDialer := &tls.Dialer{
NetDialer: dialer,
Config: tlsConfig,
}
// Establish TLS connection
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: dialTimeout}, "tcp", serverAddr, tlsConfig)
conn, err := tlsDialer.DialContext(ctx, "tcp", serverAddr)
if err != nil {
return nil, err
}
deadline, hasDeadline := ctx.Deadline()
if hasDeadline {
err := conn.SetWriteDeadline(deadline)
if err != nil {
_ = conn.Close()
return nil, err
}
}
// Send the stream header once as a handshake.
if _, err := conn.Write(header.Bytes()); err != nil {
_ = conn.Close()
return nil, err
}
if hasDeadline {
// reset write deadline
err = conn.SetWriteDeadline(time.Time{})
if err != nil {
_ = conn.Close()
return nil, err
}
}
return &TCPClient{
conn: conn,
}, nil

View File

@@ -12,7 +12,7 @@ func TestTCPHealthCheck(t *testing.T) {
srv := startTCPServer(t, certs)
err := stream.TCPHealthCheck(srv.Addr.String(), certs.CaCert, certs.ClientCert)
err := stream.TCPHealthCheck(t.Context(), srv.Addr.String(), certs.CaCert, certs.ClientCert)
require.NoError(t, err, "health check")
}
@@ -21,6 +21,6 @@ func TestUDPHealthCheck(t *testing.T) {
srv := startUDPServer(t, certs)
err := stream.UDPHealthCheck(srv.Addr.String(), certs.CaCert, certs.ClientCert)
err := stream.UDPHealthCheck(t.Context(), srv.Addr.String(), certs.CaCert, certs.ClientCert)
require.NoError(t, err, "health check")
}

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

@@ -1,6 +1,7 @@
package stream
import (
"context"
"crypto/tls"
"crypto/x509"
"net"
@@ -35,10 +36,10 @@ func NewUDPClient(serverAddr, targetAddress string, caCert *x509.Certificate, cl
return nil, err
}
return newUDPClientWIthHeader(serverAddr, header, caCert, clientCert)
return newUDPClientWIthHeader(context.Background(), serverAddr, header, caCert, clientCert)
}
func newUDPClientWIthHeader(serverAddr string, header *StreamRequestHeader, caCert *x509.Certificate, clientCert *tls.Certificate) (net.Conn, error) {
func newUDPClientWIthHeader(ctx context.Context, serverAddr string, header *StreamRequestHeader, caCert *x509.Certificate, clientCert *tls.Certificate) (net.Conn, error) {
// Setup DTLS configuration
caCertPool := x509.NewCertPool()
caCertPool.AddCert(caCert)
@@ -62,21 +63,40 @@ func newUDPClientWIthHeader(serverAddr string, header *StreamRequestHeader, caCe
if err != nil {
return nil, err
}
deadline, hasDeadline := ctx.Deadline()
if hasDeadline {
err := conn.SetWriteDeadline(deadline)
if err != nil {
_ = conn.Close()
return nil, err
}
}
// Send the stream header once as a handshake.
if _, err := conn.Write(header.Bytes()); err != nil {
_ = conn.Close()
return nil, err
}
if hasDeadline {
// reset write deadline
err = conn.SetWriteDeadline(time.Time{})
if err != nil {
_ = conn.Close()
return nil, err
}
}
return &UDPClient{
conn: conn,
}, nil
}
func UDPHealthCheck(serverAddr string, caCert *x509.Certificate, clientCert *tls.Certificate) error {
func UDPHealthCheck(ctx context.Context, serverAddr string, caCert *x509.Certificate, clientCert *tls.Certificate) error {
header := NewStreamHealthCheckHeader()
conn, err := newUDPClientWIthHeader(serverAddr, header, caCert, clientCert)
conn, err := newUDPClientWIthHeader(ctx, serverAddr, header, caCert, clientCert)
if err != nil {
return err
}

View File

@@ -2,11 +2,11 @@ package agentproxy
import (
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/bytedance/sonic"
route "github.com/yusing/godoxy/internal/route/types"
)
@@ -53,7 +53,7 @@ func proxyConfigFromHeaders(h http.Header) (cfg Config, err error) {
return cfg, err
}
err = sonic.Unmarshal(cfgJSON, &cfg)
err = json.Unmarshal(cfgJSON, &cfg)
return cfg, err
}
@@ -67,7 +67,7 @@ func (cfg *Config) SetAgentProxyConfigHeadersLegacy(h http.Header) {
func (cfg *Config) SetAgentProxyConfigHeaders(h http.Header) {
h.Set(HeaderXProxyHost, cfg.Host)
h.Set(HeaderXProxyScheme, string(cfg.Scheme))
cfgJSON, _ := sonic.Marshal(cfg.HTTPConfig)
cfgJSON, _ := json.Marshal(cfg.HTTPConfig)
cfgBase64 := base64.StdEncoding.EncodeToString(cfgJSON)
h.Set(HeaderXProxyConfig, cfgBase64)
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"net"
"net/http"
"net/url"
@@ -8,7 +9,6 @@ import (
"strings"
"time"
"github.com/bytedance/sonic"
healthcheck "github.com/yusing/godoxy/internal/health/check"
"github.com/yusing/godoxy/internal/types"
)
@@ -73,7 +73,7 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
sonic.ConfigDefault.NewEncoder(w).Encode(result)
json.NewEncoder(w).Encode(result)
}
func parseMsOrDefault(msStr string) time.Duration {

View File

@@ -1,9 +1,9 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/bytedance/sonic"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/yusing/godoxy/agent/pkg/agent"
@@ -51,7 +51,7 @@ func NewAgentHandler() http.Handler {
Runtime: env.Runtime,
}
w.Header().Set("Content-Type", "application/json")
sonic.ConfigDefault.NewEncoder(w).Encode(agentInfo)
json.NewEncoder(w).Encode(agentInfo)
})
mux.HandleEndpoint("GET", agent.EndpointHealth, CheckHealth)
mux.HandleEndpoint("GET", agent.EndpointSystemInfo, metricsHandler.ServeHTTP)

View File

@@ -38,7 +38,7 @@ func main() {
select {
case <-done:
return
case <-time.After(time.Second * 10):
case <-time.After(common.InitTimeout):
log.Fatal().Msgf("timeout waiting for initialization to complete, exiting...")
}
}()

48
go.mod
View File

@@ -14,9 +14,12 @@ replace (
github.com/yusing/goutils/server => ./goutils/server
)
exclude github.com/luthermonson/go-proxmox v0.3.0
require (
github.com/PuerkitoBio/goquery v1.11.0 // parsing HTML for extract fav icon; modify_html middleware
github.com/coreos/go-oidc/v3 v3.17.0 // oidc authentication
github.com/docker/docker v28.5.2+incompatible // docker daemon
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
@@ -25,7 +28,7 @@ require (
github.com/gorilla/websocket v1.5.3 // websocket for API and agent
github.com/gotify/server/v2 v2.8.0 // reference the Message struct for json response
github.com/lithammer/fuzzysearch v1.1.8 // fuzzy search for searching icons and filtering metrics
github.com/pires/go-proxyproto v0.9.1 // proxy protocol support
github.com/pires/go-proxyproto v0.9.2 // proxy protocol support
github.com/puzpuzpuz/xsync/v4 v4.4.0 // lock free map for concurrent operations
github.com/rs/zerolog v1.34.0 // logging
github.com/vincent-petithory/dataurl v1.0.0 // data url for fav icon
@@ -38,27 +41,25 @@ require (
require (
github.com/bytedance/gopkg v0.1.3 // xxhash64 for fast hash
github.com/bytedance/sonic v1.15.0 // fast json parsing
github.com/docker/cli v29.1.5+incompatible // needs docker/cli/cli/connhelper connection helper for docker client
github.com/bytedance/sonic v1.15.0 // indirect; fast json parsing
github.com/docker/cli v29.2.0+incompatible // needs docker/cli/cli/connhelper connection helper for docker client
github.com/goccy/go-yaml v1.19.2 // yaml parsing for different config files
github.com/golang-jwt/jwt/v5 v5.3.0 // jwt authentication
github.com/luthermonson/go-proxmox v0.3.2 // proxmox API client
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
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/luthermonson/go-proxmox v0.3.2
github.com/oschwald/maxminddb-golang v1.13.1
github.com/quic-go/quic-go v0.59.0 // http3 support
github.com/shirou/gopsutil/v4 v4.25.12 // system information
github.com/spf13/afero v1.15.0 // afero for file system operations
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-20260125091326-9c2051840fd9
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260124133347-9a96f3cc539e
github.com/yusing/godoxy/agent v0.0.0-20260129101716-0f13004ad6ba
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260129101716-0f13004ad6ba
github.com/yusing/gointernals v0.1.16
github.com/yusing/goutils v0.7.0
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260125040745-bcc4b498f878
github.com/yusing/goutils/http/websocket v0.0.0-20260125040745-bcc4b498f878
github.com/yusing/goutils/server v0.0.0-20260125040745-bcc4b498f878
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260129081554-24e52ede7468
github.com/yusing/goutils/http/websocket v0.0.0-20260129081554-24e52ede7468
github.com/yusing/goutils/server v0.0.0-20260129081554-24e52ede7468
)
require (
@@ -116,11 +117,11 @@ require (
github.com/ovh/go-ovh v1.9.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/samber/slog-common v0.19.0 // indirect
github.com/samber/slog-zerolog/v2 v2.9.0 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
@@ -136,8 +137,8 @@ require (
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/api v0.262.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
google.golang.org/api v0.263.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
@@ -145,7 +146,10 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require github.com/moby/moby/api v1.53.0
require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
@@ -155,6 +159,7 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
@@ -164,28 +169,33 @@ require (
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
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/linode/linodego v1.64.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/nrdcg/goinwx v0.12.0 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pion/dtls/v3 v3.0.10 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/samber/slog-zerolog/v2 v2.9.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vultr/govultr/v3 v3.26.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/arch v0.23.0 // indirect
)

68
go.sum
View File

@@ -23,6 +23,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourceg
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
@@ -65,6 +67,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -76,8 +80,10 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v29.1.5+incompatible h1:GckbANUt3j+lsnQ6eCcQd70mNSOismSHWt8vk2AX8ao=
github.com/docker/cli v29.1.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -137,8 +143,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -159,6 +165,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
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/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
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=
@@ -214,23 +222,29 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.2.1 h1:1Grh1552mvv6i+sYOdY+xKKVTvzJegcVMhuXocyDz/k=
github.com/moby/moby/client v0.2.1/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE=
github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0=
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1 h1:+fx2mbWeR8XX/vidwpRMepJMtRIYQP44Iezm2oeObVM=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1 h1:GDhBiaIAm/QXLzHJ0ASDdY/6R/9w60+gk8lY5rgfxEQ=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1/go.mod h1:EHScJdbM0gg5Is7e3C0ceRYAFMMsfP4Vf8sBRoxoTgk=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0 h1:eMzyN+jGJbxG4ut278uwIsUo9XacXc711lFjhKnaUso=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0 h1:t34IpOa+8NfmjkU8bdWtYrLrmr346/FGhu8FlpJDQok=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0/go.mod h1:p95/OxVsdx71I2Qrck1GtIS87sRxcTRKXzUi5nWm9NY=
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=
@@ -251,10 +265,11 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pires/go-proxyproto v0.9.1 h1:wTPjpyk41pJm1Im9BqHtPLuhxfjxL+qNfSikx9ux0WY=
github.com/pires/go-proxyproto v0.9.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U=
github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
@@ -312,8 +327,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
@@ -341,6 +356,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGN
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
@@ -349,6 +368,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
@@ -400,6 +421,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -447,14 +469,14 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.262.0 h1:4B+3u8He2GwyN8St3Jhnd3XRHlIvc//sBmgHSp78oNY=
google.golang.org/api v0.262.0/go.mod h1:jNwmH8BgUBJ/VrUG6/lIl9YiildyLd09r9ZLHiQ6cGI=
google.golang.org/api v0.263.0 h1:UFs7qn8gInIdtk1ZA6eXRXp5JDAnS4x9VRsRVCeKdbk=
google.golang.org/api v0.263.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -472,5 +494,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

Submodule goutils updated: 272bc53439...e5fba76994

View File

@@ -106,7 +106,7 @@ func (c *Config) Validate() gperr.Error {
c.allowLocal = true
}
if c.Notify.Interval < 0 {
if c.Notify.Interval <= 0 {
c.Notify.Interval = defaultNotifyInterval
}

View File

@@ -27,6 +27,7 @@ func newAgent(cfg *agent.AgentConfig) *Agent {
AgentConfig: cfg,
httpClient: &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
},
fasthttpHcClient: &fasthttp.Client{
DialTimeout: func(addr string, timeout time.Duration) (net.Conn, error) {

View File

@@ -2,12 +2,12 @@ package agentpool
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/bytedance/sonic"
"github.com/gorilla/websocket"
"github.com/valyala/fasthttp"
"github.com/yusing/godoxy/agent/pkg/agent"
@@ -63,7 +63,7 @@ func (cfg *Agent) DoHealthCheck(timeout time.Duration, query string) (ret Health
ret.Detail = fmt.Sprintf("HTTP %d %s", status, resp.Body())
return ret, nil
} else {
err = sonic.Unmarshal(resp.Body(), &ret)
err = json.Unmarshal(resp.Body(), &ret)
if err != nil {
return ret, err
}

View File

@@ -2,10 +2,8 @@ package api
import (
"net/http"
"reflect"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/codec/json"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
apiV1 "github.com/yusing/godoxy/internal/api/v1"
@@ -48,8 +46,6 @@ func NewHandler(requireAuth bool) *gin.Engine {
r.Use(ErrorLoggingMiddleware())
r.Use(NoCache())
log.Debug().Msg("gin codec json.API: " + reflect.TypeOf(json.API).Name())
r.GET("/api/v1/version", apiV1.Version)
if auth.IsEnabled() && requireAuth {
@@ -86,6 +82,8 @@ func NewHandler(requireAuth bool) *gin.Engine {
route.GET("/providers", routeApi.Providers)
route.GET("/by_provider", routeApi.ByProvider)
route.POST("/playground", routeApi.Playground)
route.GET("/validate", routeApi.Validate) // websocket
route.POST("/validate", routeApi.Validate)
}
file := v1.Group("/file")
@@ -146,6 +144,8 @@ func NewHandler(requireAuth bool) *gin.Engine {
proxmox := v1.Group("/proxmox")
{
proxmox.GET("/tail", proxmoxApi.Tail)
proxmox.GET("/journalctl", proxmoxApi.Journalctl)
proxmox.GET("/journalctl/:node", proxmoxApi.Journalctl)
proxmox.GET("/journalctl/:node/:vmid", proxmoxApi.Journalctl)
proxmox.GET("/journalctl/:node/:vmid/:service", proxmoxApi.Journalctl)

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/moby/moby/client"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
)
@@ -43,22 +42,22 @@ func GetContainer(c *gin.Context) {
defer dockerClient.Close()
cont, err := dockerClient.ContainerInspect(c.Request.Context(), id, client.ContainerInspectOptions{})
cont, err := dockerClient.ContainerInspect(c.Request.Context(), id)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to inspect container"))
return
}
var state ContainerState
if cont.Container.State != nil {
state = cont.Container.State.Status
if cont.State != nil {
state = cont.State.Status
}
c.JSON(http.StatusOK, &Container{
Server: dockerCfg.URL,
Name: cont.Container.Name,
ID: cont.Container.ID,
Image: cont.Container.Image,
Name: cont.Name,
ID: cont.ID,
Image: cont.Image,
State: state,
})
}

View File

@@ -4,9 +4,8 @@ import (
"context"
"sort"
"github.com/docker/docker/api/types/container"
"github.com/gin-gonic/gin"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
gperr "github.com/yusing/goutils/errs"
_ "github.com/yusing/goutils/apitypes"
@@ -40,12 +39,12 @@ func GetContainers(ctx context.Context, dockerClients DockerClients) ([]Containe
errs := gperr.NewBuilder("failed to get containers")
containers := make([]Container, 0)
for server, dockerClient := range dockerClients {
conts, err := dockerClient.ContainerList(ctx, client.ContainerListOptions{All: true})
conts, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
errs.Add(err)
continue
}
for _, cont := range conts.Items {
for _, cont := range conts {
containers = append(containers, Container{
Server: server,
Name: cont.Names[0],

View File

@@ -4,9 +4,8 @@ import (
"context"
"sort"
dockerSystem "github.com/docker/docker/api/types/system"
"github.com/gin-gonic/gin"
dockerSystem "github.com/moby/moby/api/types/system"
"github.com/moby/moby/client"
gperr "github.com/yusing/goutils/errs"
strutils "github.com/yusing/goutils/strings"
@@ -65,13 +64,13 @@ func GetDockerInfo(ctx context.Context, dockerClients DockerClients) ([]dockerIn
i := 0
for name, dockerClient := range dockerClients {
info, err := dockerClient.Info(ctx, client.InfoOptions{})
info, err := dockerClient.Info(ctx)
if err != nil {
errs.Add(err)
continue
}
info.Info.Name = name
dockerInfos[i] = toDockerInfo(info.Info)
info.Name = name
dockerInfos[i] = toDockerInfo(info)
i++
}

View File

@@ -7,9 +7,9 @@ import (
"net/http"
"strconv"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
"github.com/gin-gonic/gin"
"github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/client"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
@@ -23,7 +23,7 @@ type LogsQueryParams struct {
Since string `form:"from"`
Until string `form:"to"`
Levels string `form:"levels"`
Limit int `form:"limit,default=100" binding:"omitempty,min=1,max=1000"`
Limit int `form:"limit,default=100" binding:"min=1,max=1000"`
} // @name LogsQueryParams
// @x-id "logs"
@@ -73,7 +73,7 @@ func Logs(c *gin.Context) {
}
defer dockerClient.Close()
opts := client.ContainerLogsOptions{
opts := container.LogsOptions{
ShowStdout: queryParams.Stdout,
ShowStderr: queryParams.Stderr,
Since: queryParams.Since,

View File

@@ -4,23 +4,17 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/moby/moby/client"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
)
type RestartRequest struct {
ID string `json:"id" binding:"required"`
client.ContainerRestartOptions
}
// @x-id "restart"
// @BasePath /api/v1
// @Summary Restart container
// @Description Restart container by container id
// @Tags docker
// @Produce json
// @Param request body RestartRequest true "Request"
// @Param request body StopRequest true "Request"
// @Success 200 {object} apitypes.SuccessResponse
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
// @Failure 403 {object} apitypes.ErrorResponse
@@ -28,7 +22,7 @@ type RestartRequest struct {
// @Failure 500 {object} apitypes.ErrorResponse
// @Router /docker/restart [post]
func Restart(c *gin.Context) {
var req RestartRequest
var req StopRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
return
@@ -48,7 +42,7 @@ func Restart(c *gin.Context) {
defer client.Close()
_, err = client.ContainerRestart(c.Request.Context(), req.ID, req.ContainerRestartOptions)
err = client.ContainerRestart(c.Request.Context(), req.ID, req.StopOptions)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to restart container"))
return

View File

@@ -3,15 +3,15 @@ package dockerapi
import (
"net/http"
"github.com/docker/docker/api/types/container"
"github.com/gin-gonic/gin"
"github.com/moby/moby/client"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
)
type StartRequest struct {
ID string `json:"id" binding:"required"`
client.ContainerStartOptions
container.StartOptions
}
// @x-id "start"
@@ -48,7 +48,7 @@ func Start(c *gin.Context) {
defer client.Close()
_, err = client.ContainerStart(c.Request.Context(), req.ID, req.ContainerStartOptions)
err = client.ContainerStart(c.Request.Context(), req.ID, req.StartOptions)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to start container"))
return

View File

@@ -8,7 +8,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/types"
@@ -68,7 +67,7 @@ func Stats(c *gin.Context) {
defer dockerClient.Close()
if httpheaders.IsWebsocket(c.Request.Header) {
stats, err := dockerClient.ContainerStats(c.Request.Context(), id, client.ContainerStatsOptions{Stream: true})
stats, err := dockerClient.ContainerStats(c.Request.Context(), id, true)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to get container stats"))
return
@@ -102,7 +101,7 @@ func Stats(c *gin.Context) {
}
}
stats, err := dockerClient.ContainerStats(c.Request.Context(), id, client.ContainerStatsOptions{Stream: false})
stats, err := dockerClient.ContainerStats(c.Request.Context(), id, false)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to get container stats"))
return

View File

@@ -3,15 +3,15 @@ package dockerapi
import (
"net/http"
"github.com/docker/docker/api/types/container"
"github.com/gin-gonic/gin"
"github.com/moby/moby/client"
"github.com/yusing/godoxy/internal/docker"
apitypes "github.com/yusing/goutils/apitypes"
)
type StopRequest struct {
ID string `json:"id" binding:"required"`
client.ContainerStopOptions
container.StopOptions
}
// @x-id "stop"
@@ -48,7 +48,7 @@ func Stop(c *gin.Context) {
defer client.Close()
_, err = client.ContainerStop(c.Request.Context(), req.ID, req.ContainerStopOptions)
err = client.ContainerStop(c.Request.Context(), req.ID, req.StopOptions)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to stop container"))
return

View File

@@ -1087,7 +1087,7 @@
"post": {
"description": "Validate file",
"consumes": [
"text/plain"
"application/yaml"
],
"produces": [
"application/json"
@@ -2077,6 +2077,90 @@
"operationId": "uptime"
}
},
"/proxmox/journalctl": {
"get": {
"description": "Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"proxmox",
"websocket"
],
"summary": "Get journalctl output",
"parameters": [
{
"maximum": 1000,
"minimum": 1,
"type": "integer",
"default": 100,
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "query",
"required": true
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "Service names",
"name": "service",
"in": "query"
},
{
"type": "integer",
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "query"
}
],
"responses": {
"200": {
"description": "Journalctl output",
"schema": {
"type": "string"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"403": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Node not found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
},
"x-id": "journalctl",
"operationId": "journalctl"
}
},
"/proxmox/journalctl/{node}": {
"get": {
"description": "Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.",
@@ -2092,18 +2176,44 @@
],
"summary": "Get journalctl output",
"parameters": [
{
"maximum": 1000,
"minimum": 1,
"type": "integer",
"default": 100,
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "query",
"required": true
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "Service names",
"name": "service",
"in": "query"
},
{
"type": "integer",
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "query"
},
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
}
],
"responses": {
@@ -2157,6 +2267,38 @@
],
"summary": "Get journalctl output",
"parameters": [
{
"maximum": 1000,
"minimum": 1,
"type": "integer",
"default": 100,
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "query",
"required": true
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "Service names",
"name": "service",
"in": "query"
},
{
"type": "integer",
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "query"
},
{
"type": "string",
"description": "Node name",
@@ -2169,12 +2311,6 @@
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "path"
},
{
"type": "integer",
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
}
],
"responses": {
@@ -2228,6 +2364,38 @@
],
"summary": "Get journalctl output",
"parameters": [
{
"maximum": 1000,
"minimum": 1,
"type": "integer",
"default": 100,
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "query",
"required": true
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "Service names",
"name": "service",
"in": "query"
},
{
"type": "integer",
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "query"
},
{
"type": "string",
"description": "Node name",
@@ -2236,22 +2404,20 @@
"required": true
},
{
"type": "integer",
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "path"
},
{
"type": "string",
"description": "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)",
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "Service names",
"name": "service",
"in": "path"
},
{
"type": "integer",
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "path"
}
],
"responses": {
@@ -2569,6 +2735,91 @@
"operationId": "vmStats"
}
},
"/proxmox/tail": {
"get": {
"description": "Get tail output for node or LXC container. If vmid is not provided, streams node tail.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"proxmox",
"websocket"
],
"summary": "Get tail output",
"parameters": [
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "File paths",
"name": "file",
"in": "query",
"required": true
},
{
"maximum": 1000,
"minimum": 1,
"type": "integer",
"default": 100,
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "query"
}
],
"responses": {
"200": {
"description": "Tail output",
"schema": {
"type": "string"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"403": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Node not found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
},
"x-id": "tail",
"operationId": "tail"
}
},
"/reload": {
"post": {
"description": "Reload config",
@@ -2775,6 +3026,122 @@
"operationId": "providers"
}
},
"/route/validate": {
"get": {
"description": "Validate route,",
"consumes": [
"application/yaml"
],
"produces": [
"application/json"
],
"tags": [
"route",
"websocket"
],
"summary": "Validate route",
"parameters": [
{
"description": "Route",
"name": "route",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/Route"
}
}
],
"responses": {
"200": {
"description": "Route validated",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"417": {
"description": "Validation failed",
"schema": {}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
},
"x-id": "validate",
"operationId": "validate"
},
"post": {
"description": "Validate route,",
"consumes": [
"application/yaml"
],
"produces": [
"application/json"
],
"tags": [
"route",
"websocket"
],
"summary": "Validate route",
"parameters": [
{
"description": "Route",
"name": "route",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/Route"
}
}
],
"responses": {
"200": {
"description": "Route validated",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"417": {
"description": "Validation failed",
"schema": {}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
},
"x-id": "validate",
"operationId": "validate"
}
},
"/route/{which}": {
"get": {
"description": "List route",
@@ -3389,33 +3756,6 @@
"x-nullable": false,
"x-omitempty": false
},
"DockerConfig": {
"type": "object",
"required": [
"container_id",
"container_name",
"docker_cfg"
],
"properties": {
"container_id": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"container_name": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"docker_cfg": {
"$ref": "#/definitions/DockerProviderConfig",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"DockerProviderConfig": {
"type": "object",
"properties": {
@@ -4164,7 +4504,7 @@
"x-omitempty": false
},
"docker": {
"$ref": "#/definitions/DockerConfig",
"$ref": "#/definitions/IdlewatcherDockerConfig",
"x-nullable": false,
"x-omitempty": false
},
@@ -4184,7 +4524,7 @@
"x-omitempty": false
},
"proxmox": {
"$ref": "#/definitions/ProxmoxNodeConfig",
"$ref": "#/definitions/IdlewatcherProxmoxNodeConfig",
"x-nullable": false,
"x-omitempty": false
},
@@ -4218,6 +4558,54 @@
"x-nullable": false,
"x-omitempty": false
},
"IdlewatcherDockerConfig": {
"type": "object",
"required": [
"container_id",
"container_name",
"docker_cfg"
],
"properties": {
"container_id": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"container_name": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"docker_cfg": {
"$ref": "#/definitions/DockerProviderConfig",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"IdlewatcherProxmoxNodeConfig": {
"type": "object",
"required": [
"node",
"vmid"
],
"properties": {
"node": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"vmid": {
"type": "integer",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"ListFilesResponse": {
"type": "object",
"properties": {
@@ -4805,26 +5193,38 @@
},
"ProxmoxNodeConfig": {
"type": "object",
"required": [
"node",
"vmid"
],
"properties": {
"files": {
"type": "array",
"items": {
"type": "string"
},
"x-nullable": false,
"x-omitempty": false
},
"node": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"service": {
"type": "string"
"services": {
"type": "array",
"items": {
"type": "string"
},
"x-nullable": false,
"x-omitempty": false
},
"vmid": {
"description": "unset: auto discover; explicit 0: node-level route; >0: lxc/qemu resource route",
"type": "integer",
"x-nullable": false,
"x-omitempty": false
},
"vmname": {
"type": "string"
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
@@ -6461,229 +6861,6 @@
"x-nullable": false,
"x-omitempty": false
},
"route.Route": {
"type": "object",
"properties": {
"access_log": {
"allOf": [
{
"$ref": "#/definitions/RequestLoggerConfig"
}
],
"x-nullable": true
},
"agent": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"alias": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"bind": {
"description": "for TCP and UDP routes, bind address to listen on",
"type": "string",
"x-nullable": true
},
"container": {
"description": "Docker only",
"allOf": [
{
"$ref": "#/definitions/Container"
}
],
"x-nullable": true
},
"disable_compression": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"excluded": {
"type": "boolean",
"x-nullable": true
},
"excluded_reason": {
"type": "string",
"x-nullable": true
},
"health": {
"description": "for swagger",
"allOf": [
{
"$ref": "#/definitions/HealthJSON"
}
],
"x-nullable": false,
"x-omitempty": false
},
"healthcheck": {
"description": "null on load-balancer routes",
"allOf": [
{
"$ref": "#/definitions/HealthCheckConfig"
}
],
"x-nullable": true
},
"homepage": {
"$ref": "#/definitions/HomepageItemConfig",
"x-nullable": false,
"x-omitempty": false
},
"host": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"idlewatcher": {
"allOf": [
{
"$ref": "#/definitions/IdlewatcherConfig"
}
],
"x-nullable": true
},
"index": {
"description": "Index file to serve for single-page app mode",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"load_balance": {
"allOf": [
{
"$ref": "#/definitions/LoadBalancerConfig"
}
],
"x-nullable": true
},
"lurl": {
"description": "private fields",
"type": "string",
"x-nullable": true
},
"middlewares": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/types.LabelMap"
},
"x-nullable": true
},
"no_tls_verify": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"path_patterns": {
"type": "array",
"items": {
"type": "string"
},
"x-nullable": true
},
"port": {
"$ref": "#/definitions/Port",
"x-nullable": false,
"x-omitempty": false
},
"provider": {
"description": "for backward compatibility",
"type": "string",
"x-nullable": true
},
"proxmox": {
"allOf": [
{
"$ref": "#/definitions/ProxmoxNodeConfig"
}
],
"x-nullable": true
},
"purl": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"response_header_timeout": {
"type": "integer",
"x-nullable": false,
"x-omitempty": false
},
"root": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"rule_file": {
"type": "string",
"x-nullable": true
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/rules.Rule"
},
"x-nullable": true
},
"scheme": {
"type": "string",
"enum": [
"http",
"https",
"h2c",
"tcp",
"udp",
"fileserver"
],
"x-nullable": false,
"x-omitempty": false
},
"spa": {
"description": "Single-page app mode: serves index for non-existent paths",
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate": {
"description": "Path to client certificate",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate_key": {
"description": "Path to client certificate key",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"ssl_protocols": {
"description": "Allowed TLS protocols",
"type": "array",
"items": {
"type": "string"
},
"x-nullable": false,
"x-omitempty": false
},
"ssl_server_name": {
"description": "SSL/TLS proxy options (nginx-like)",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"ssl_trusted_certificate": {
"description": "Path to trusted CA certificates",
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"routeApi.RawRule": {
"type": "object",
"properties": {
@@ -6711,7 +6888,7 @@
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/definitions/route.Route"
"$ref": "#/definitions/Route"
}
},
"x-nullable": false,

View File

@@ -269,19 +269,6 @@ definitions:
- ContainerStopMethodPause
- ContainerStopMethodStop
- ContainerStopMethodKill
DockerConfig:
properties:
container_id:
type: string
container_name:
type: string
docker_cfg:
$ref: '#/definitions/DockerProviderConfig'
required:
- container_id
- container_name
- docker_cfg
type: object
DockerProviderConfig:
properties:
tls:
@@ -630,7 +617,7 @@ definitions:
type: string
type: array
docker:
$ref: '#/definitions/DockerConfig'
$ref: '#/definitions/IdlewatcherDockerConfig'
idle_timeout:
allOf:
- $ref: '#/definitions/time.Duration'
@@ -641,7 +628,7 @@ definitions:
no_loading_page:
type: boolean
proxmox:
$ref: '#/definitions/ProxmoxNodeConfig'
$ref: '#/definitions/IdlewatcherProxmoxNodeConfig'
start_endpoint:
description: Optional path that must be hit to start container
type: string
@@ -654,6 +641,29 @@ definitions:
wake_timeout:
$ref: '#/definitions/time.Duration'
type: object
IdlewatcherDockerConfig:
properties:
container_id:
type: string
container_name:
type: string
docker_cfg:
$ref: '#/definitions/DockerProviderConfig'
required:
- container_id
- container_name
- docker_cfg
type: object
IdlewatcherProxmoxNodeConfig:
properties:
node:
type: string
vmid:
type: integer
required:
- node
- vmid
type: object
ListFilesResponse:
properties:
config:
@@ -935,17 +945,22 @@ definitions:
- ProviderTypeAgent
ProxmoxNodeConfig:
properties:
files:
items:
type: string
type: array
node:
type: string
service:
type: string
services:
items:
type: string
type: array
vmid:
description: 'unset: auto discover; explicit 0: node-level route; >0: lxc/qemu
resource route'
type: integer
vmname:
type: string
required:
- node
- vmid
type: object
ProxyStats:
properties:
@@ -1815,127 +1830,6 @@ definitions:
uptime:
type: string
type: object
route.Route:
properties:
access_log:
allOf:
- $ref: '#/definitions/RequestLoggerConfig'
x-nullable: true
agent:
type: string
alias:
type: string
bind:
description: for TCP and UDP routes, bind address to listen on
type: string
x-nullable: true
container:
allOf:
- $ref: '#/definitions/Container'
description: Docker only
x-nullable: true
disable_compression:
type: boolean
excluded:
type: boolean
x-nullable: true
excluded_reason:
type: string
x-nullable: true
health:
allOf:
- $ref: '#/definitions/HealthJSON'
description: for swagger
healthcheck:
allOf:
- $ref: '#/definitions/HealthCheckConfig'
description: null on load-balancer routes
x-nullable: true
homepage:
$ref: '#/definitions/HomepageItemConfig'
host:
type: string
idlewatcher:
allOf:
- $ref: '#/definitions/IdlewatcherConfig'
x-nullable: true
index:
description: Index file to serve for single-page app mode
type: string
load_balance:
allOf:
- $ref: '#/definitions/LoadBalancerConfig'
x-nullable: true
lurl:
description: private fields
type: string
x-nullable: true
middlewares:
additionalProperties:
$ref: '#/definitions/types.LabelMap'
type: object
x-nullable: true
no_tls_verify:
type: boolean
path_patterns:
items:
type: string
type: array
x-nullable: true
port:
$ref: '#/definitions/Port'
provider:
description: for backward compatibility
type: string
x-nullable: true
proxmox:
allOf:
- $ref: '#/definitions/ProxmoxNodeConfig'
x-nullable: true
purl:
type: string
response_header_timeout:
type: integer
root:
type: string
rule_file:
type: string
x-nullable: true
rules:
items:
$ref: '#/definitions/rules.Rule'
type: array
x-nullable: true
scheme:
enum:
- http
- https
- h2c
- tcp
- udp
- fileserver
type: string
spa:
description: 'Single-page app mode: serves index for non-existent paths'
type: boolean
ssl_certificate:
description: Path to client certificate
type: string
ssl_certificate_key:
description: Path to client certificate key
type: string
ssl_protocols:
description: Allowed TLS protocols
items:
type: string
type: array
ssl_server_name:
description: SSL/TLS proxy options (nginx-like)
type: string
ssl_trusted_certificate:
description: Path to trusted CA certificates
type: string
type: object
routeApi.RawRule:
properties:
do:
@@ -1948,7 +1842,7 @@ definitions:
routeApi.RoutesByProvider:
additionalProperties:
items:
$ref: '#/definitions/route.Route'
$ref: '#/definitions/Route'
type: array
type: object
rules.Rule:
@@ -2726,7 +2620,7 @@ paths:
/file/validate:
post:
consumes:
- text/plain
- application/yaml
description: Validate file
parameters:
- description: Type
@@ -3392,6 +3286,64 @@ paths:
- metrics
- websocket
x-id: uptime
/proxmox/journalctl:
get:
consumes:
- application/json
description: Get journalctl output for node or LXC container. If vmid is not
provided, streams node journalctl.
parameters:
- default: 100
description: Limit output lines (1-1000)
in: query
maximum: 1000
minimum: 1
name: limit
type: integer
- description: Node name
in: query
name: node
required: true
type: string
- collectionFormat: csv
description: Service names
in: query
items:
type: string
name: service
type: array
- description: Container VMID (optional - if not provided, streams node journalctl)
in: query
name: vmid
type: integer
produces:
- application/json
responses:
"200":
description: Journalctl output
schema:
type: string
"400":
description: Invalid request
schema:
$ref: '#/definitions/ErrorResponse'
"403":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Node not found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get journalctl output
tags:
- proxmox
- websocket
x-id: journalctl
/proxmox/journalctl/{node}:
get:
consumes:
@@ -3399,15 +3351,34 @@ paths:
description: Get journalctl output for node or LXC container. If vmid is not
provided, streams node journalctl.
parameters:
- default: 100
description: Limit output lines (1-1000)
in: query
maximum: 1000
minimum: 1
name: limit
type: integer
- description: Node name
in: query
name: node
required: true
type: string
- collectionFormat: csv
description: Service names
in: query
items:
type: string
name: service
type: array
- description: Container VMID (optional - if not provided, streams node journalctl)
in: query
name: vmid
type: integer
- description: Node name
in: path
name: node
required: true
type: string
- description: Limit output lines (1-1000)
in: query
name: limit
type: integer
produces:
- application/json
responses:
@@ -3443,6 +3414,29 @@ paths:
description: Get journalctl output for node or LXC container. If vmid is not
provided, streams node journalctl.
parameters:
- default: 100
description: Limit output lines (1-1000)
in: query
maximum: 1000
minimum: 1
name: limit
type: integer
- description: Node name
in: query
name: node
required: true
type: string
- collectionFormat: csv
description: Service names
in: query
items:
type: string
name: service
type: array
- description: Container VMID (optional - if not provided, streams node journalctl)
in: query
name: vmid
type: integer
- description: Node name
in: path
name: node
@@ -3452,10 +3446,6 @@ paths:
in: path
name: vmid
type: integer
- description: Limit output lines (1-1000)
in: query
name: limit
type: integer
produces:
- application/json
responses:
@@ -3491,24 +3481,45 @@ paths:
description: Get journalctl output for node or LXC container. If vmid is not
provided, streams node journalctl.
parameters:
- default: 100
description: Limit output lines (1-1000)
in: query
maximum: 1000
minimum: 1
name: limit
type: integer
- description: Node name
in: query
name: node
required: true
type: string
- collectionFormat: csv
description: Service names
in: query
items:
type: string
name: service
type: array
- description: Container VMID (optional - if not provided, streams node journalctl)
in: query
name: vmid
type: integer
- description: Node name
in: path
name: node
required: true
type: string
- collectionFormat: csv
description: Service names
in: path
items:
type: string
name: service
type: array
- description: Container VMID (optional - if not provided, streams node journalctl)
in: path
name: vmid
type: integer
- description: Service name (e.g., 'pveproxy' for node, 'container@.service'
format for LXC)
in: path
name: service
type: string
- description: Limit output lines (1-1000)
in: query
name: limit
type: integer
produces:
- application/json
responses:
@@ -3720,6 +3731,65 @@ paths:
- proxmox
- websocket
x-id: vmStats
/proxmox/tail:
get:
consumes:
- application/json
description: Get tail output for node or LXC container. If vmid is not provided,
streams node tail.
parameters:
- collectionFormat: csv
description: File paths
in: query
items:
type: string
name: file
required: true
type: array
- default: 100
description: Limit output lines (1-1000)
in: query
maximum: 1000
minimum: 1
name: limit
type: integer
- description: Node name
in: query
name: node
required: true
type: string
- description: Container VMID (optional - if not provided, streams node journalctl)
in: query
name: vmid
type: integer
produces:
- application/json
responses:
"200":
description: Tail output
schema:
type: string
"400":
description: Invalid request
schema:
$ref: '#/definitions/ErrorResponse'
"403":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Node not found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get tail output
tags:
- proxmox
- websocket
x-id: tail
/reload:
post:
consumes:
@@ -3888,6 +3958,83 @@ paths:
- route
- websocket
x-id: providers
/route/validate:
get:
consumes:
- application/yaml
description: Validate route,
parameters:
- description: Route
in: body
name: route
required: true
schema:
$ref: '#/definitions/Route'
produces:
- application/json
responses:
"200":
description: Route validated
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad request
schema:
$ref: '#/definitions/ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/ErrorResponse'
"417":
description: Validation failed
schema: {}
"500":
description: Internal server error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Validate route
tags:
- route
- websocket
x-id: validate
post:
consumes:
- application/yaml
description: Validate route,
parameters:
- description: Route
in: body
name: route
required: true
schema:
$ref: '#/definitions/Route'
produces:
- application/json
responses:
"200":
description: Route validated
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad request
schema:
$ref: '#/definitions/ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/ErrorResponse'
"417":
description: Validation failed
schema: {}
"500":
description: Internal server error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Validate route
tags:
- route
- websocket
x-id: validate
/stats:
get:
consumes:

View File

@@ -20,7 +20,7 @@ type ValidateFileRequest struct {
// @Summary Validate file
// @Description Validate file
// @Tags file
// @Accept text/plain
// @Accept application/yaml
// @Produce json
// @Param type query FileType true "Type"
// @Param file body string true "File content"
@@ -29,7 +29,7 @@ type ValidateFileRequest struct {
// @Failure 403 {object} apitypes.ErrorResponse "Forbidden"
// @Failure 417 {object} any "Validation failed"
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
// @Router /file/validate [post]
// @Router /file/validate [post]
func Validate(c *gin.Context) {
var request ValidateFileRequest
if err := c.ShouldBindQuery(&request); err != nil {

View File

@@ -7,7 +7,6 @@ import (
"sync/atomic"
"time"
"github.com/bytedance/sonic"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/agent"
@@ -237,7 +236,7 @@ func marshalSystemInfo(ws *websocket.Manager, agentName string, systemInfo any)
defer bufFromPool.release(bufFromPool.RawMessage)
}
err := sonic.ConfigDefault.NewEncoder(buf).Encode(map[string]any{
err := json.NewEncoder(buf).Encode(map[string]any{
agentName: systemInfo,
})
if err != nil {

View File

@@ -3,4 +3,4 @@ package proxmoxapi
type ActionRequest struct {
Node string `uri:"node" binding:"required"`
VMID int `uri:"vmid" binding:"required"`
}
} // @name ProxmoxVMActionRequest

View File

@@ -1,6 +1,7 @@
package proxmoxapi
import (
"errors"
"io"
"net/http"
@@ -10,36 +11,40 @@ import (
"github.com/yusing/goutils/http/websocket"
)
// e.g. ws://localhost:8889/api/v1/proxmox/journalctl?node=pve&vmid=127&service=pveproxy&service=pvedaemon&limit=10
// e.g. ws://localhost:8889/api/v1/proxmox/journalctl/pve/127?service=pveproxy&service=pvedaemon&limit=10
type JournalctlRequest struct {
Node string `uri:"node" binding:"required"`
VMID *int `uri:"vmid"` // optional - if not provided, streams node journalctl
Service string `uri:"service"`
Limit int `query:"limit" binding:"omitempty,min=1,max=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:"min=1,max=1000"` // Limit output lines (1-1000)
} // @name ProxmoxJournalctlRequest
// @x-id "journalctl"
// @BasePath /api/v1
// @Summary Get journalctl output
// @Description Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.
// @Tags proxmox,websocket
// @Accept json
// @Accept json
// @Produce application/json
// @Param node path string true "Node name"
// @Param vmid path int false "Container VMID (optional - if not provided, streams node journalctl)"
// @Param service path string false "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)"
// @Param limit query int false "Limit output lines (1-1000)"
// @Param query query JournalctlRequest true "Request"
// @Param path path JournalctlRequest true "Request"
// @Success 200 string plain "Journalctl output"
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized"
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
// @Router /proxmox/journalctl [get]
// @Router /proxmox/journalctl/{node} [get]
// @Router /proxmox/journalctl/{node}/{vmid} [get]
// @Router /proxmox/journalctl/{node}/{vmid}/{service} [get]
func Journalctl(c *gin.Context) {
var request JournalctlRequest
if err := c.ShouldBindUri(&request); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
uriErr := c.ShouldBindUri(&request)
queryErr := c.ShouldBindQuery(&request)
if uriErr != nil && queryErr != nil { // allow both uri and query parameters to be set
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", errors.Join(uriErr, queryErr)))
return
}
@@ -49,18 +54,14 @@ func Journalctl(c *gin.Context) {
return
}
manager, err := websocket.NewManagerWithUpgrade(c)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
return
}
defer manager.Close()
c.Status(http.StatusContinue)
var reader io.ReadCloser
var err error
if request.VMID == nil {
reader, err = node.NodeJournalctl(c.Request.Context(), request.Service, request.Limit)
reader, err = node.NodeJournalctl(c.Request.Context(), request.Services, *request.Limit)
} else {
reader, err = node.LXCJournalctl(c.Request.Context(), *request.VMID, request.Service, request.Limit)
reader, err = node.LXCJournalctl(c.Request.Context(), *request.VMID, request.Services, *request.Limit)
}
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to get journalctl output"))
@@ -68,6 +69,13 @@ func Journalctl(c *gin.Context) {
}
defer reader.Close()
manager, err := websocket.NewManagerWithUpgrade(c)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
return
}
defer manager.Close()
writer := manager.NewWriter(websocket.TextMessage)
_, err = io.Copy(writer, reader)
if err != nil {

View File

@@ -0,0 +1,77 @@
package proxmoxapi
import (
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/proxmox"
"github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/http/websocket"
)
// e.g. ws://localhost:8889/api/v1/proxmox/tail?node=pve&vmid=127&file=/var/log/immich/web.log&file=/var/log/immich/ml.log&limit=10
type TailRequest struct {
Node string `form:"node" binding:"required"` // Node name
VMID *int `form:"vmid"` // Container VMID (optional - if not provided, streams node journalctl)
Files []string `form:"file" binding:"required,dive,filepath"` // File paths
Limit int `form:"limit" default:"100" binding:"min=1,max=1000"` // Limit output lines (1-1000)
} // @name ProxmoxTailRequest
// @x-id "tail"
// @BasePath /api/v1
// @Summary Get tail output
// @Description Get tail output for node or LXC container. If vmid is not provided, streams node tail.
// @Tags proxmox,websocket
// @Accept json
// @Produce application/json
// @Param query query TailRequest true "Request"
// @Success 200 string plain "Tail output"
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized"
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
// @Router /proxmox/tail [get]
func Tail(c *gin.Context) {
var request TailRequest
if err := c.ShouldBindQuery(&request); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
return
}
node, ok := proxmox.Nodes.Get(request.Node)
if !ok {
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
return
}
c.Status(http.StatusContinue)
var reader io.ReadCloser
var err error
if request.VMID == nil {
reader, err = node.NodeTail(c.Request.Context(), request.Files, request.Limit)
} else {
reader, err = node.LXCTail(c.Request.Context(), *request.VMID, request.Files, request.Limit)
}
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to get journalctl output"))
return
}
defer reader.Close()
manager, err := websocket.NewManagerWithUpgrade(c)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
return
}
defer manager.Close()
writer := manager.NewWriter(websocket.TextMessage)
_, err = io.Copy(writer, reader)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to copy journalctl output"))
return
}
}

View File

@@ -37,5 +37,5 @@ func Route(c *gin.Context) {
c.JSON(http.StatusOK, route)
return
}
c.JSON(http.StatusNotFound, nil)
c.JSON(http.StatusNotFound, apitypes.Error("route not found"))
}

View File

@@ -0,0 +1,69 @@
package routeApi
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/goccy/go-yaml"
"github.com/yusing/godoxy/internal/route"
"github.com/yusing/godoxy/internal/serialization"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"
)
type _ = route.Route
// @x-id "validate"
// @BasePath /api/v1
// @Summary Validate route
// @Description Validate route,
// @Tags route,websocket
// @Accept application/yaml
// @Produce json
// @Param route body route.Route true "Route"
// @Success 200 {object} apitypes.SuccessResponse "Route validated"
// @Failure 400 {object} apitypes.ErrorResponse "Bad request"
// @Failure 403 {object} apitypes.ErrorResponse "Forbidden"
// @Failure 417 {object} any "Validation failed"
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
// @Router /route/validate [get]
// @Router /route/validate [post]
func Validate(c *gin.Context) {
if httpheaders.IsWebsocket(c.Request.Header) {
ValidateWS(c)
return
}
var request route.Route
if err := c.ShouldBindWith(&request, serialization.GinYAMLBinding{}); err != nil {
c.JSON(http.StatusExpectationFailed, err)
return
}
c.JSON(http.StatusOK, apitypes.Success("route validated"))
}
func ValidateWS(c *gin.Context) {
manager, err := websocket.NewManagerWithUpgrade(c)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
return
}
defer manager.Close()
const writeTimeout = 5 * time.Second
for {
select {
case <-manager.Done():
return
case msg := <-manager.ReadCh():
var request route.Route
if err := serialization.UnmarshalValidate(msg, &request, yaml.Unmarshal); err != nil {
manager.WriteJSON(gin.H{"error": err}, writeTimeout)
continue
}
manager.WriteJSON(gin.H{"message": "route validated"}, writeTimeout)
}
}
}

View File

@@ -1,11 +1,11 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/bytedance/sonic"
"github.com/golang-jwt/jwt/v5"
"github.com/yusing/godoxy/internal/common"
gperr "github.com/yusing/goutils/errs"
@@ -109,7 +109,7 @@ type UserPassAuthCallbackRequest struct {
func (auth *UserPassAuth) PostAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
var creds UserPassAuthCallbackRequest
err := sonic.ConfigDefault.NewDecoder(r.Body).Decode(&creds)
err := json.NewDecoder(r.Body).Decode(&creds)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/dnsproviders"
@@ -25,9 +26,9 @@ func TestEABConfigRequired(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
yaml := fmt.Appendf(nil, "eab_kid: %s\neab_hmac: %s", test.cfg.EABKid, test.cfg.EABHmac)
yamlCfg := fmt.Appendf(nil, "eab_kid: %s\neab_hmac: %s", test.cfg.EABKid, test.cfg.EABHmac)
cfg := autocert.Config{}
err := serialization.UnmarshalValidateYAML(yaml, &cfg)
err := serialization.UnmarshalValidate(yamlCfg, &cfg, yaml.Unmarshal)
if (err != nil) != test.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, test.wantErr)
}

View File

@@ -6,6 +6,7 @@ import (
"os"
"testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/serialization"
@@ -41,7 +42,7 @@ func TestMultipleCertificatesLifecycle(t *testing.T) {
cfg.HTTPClient = acmeServer.httpClient()
/* unmarshal yaml config with multiple certs */
err := error(serialization.UnmarshalValidateYAML(yamlConfig, &cfg))
err := error(serialization.UnmarshalValidate(yamlConfig, &cfg, yaml.Unmarshal))
require.NoError(t, err)
require.Equal(t, []string{"main.example.com"}, cfg.Domains)
require.Len(t, cfg.Extra, 2)

View File

@@ -3,6 +3,7 @@ package autocert_test
import (
"testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/dnsproviders"
@@ -42,7 +43,7 @@ extra:
`
var cfg autocert.Config
err := error(serialization.UnmarshalValidateYAML([]byte(cfgYAML), &cfg))
err := error(serialization.UnmarshalValidate([]byte(cfgYAML), &cfg, yaml.Unmarshal))
require.NoError(t, err)
// Test: extra[0] inherits all fields from main except CertPath and KeyPath.

View File

@@ -13,6 +13,8 @@ var (
IsDebug = env.GetEnvBool("DEBUG", IsTest)
IsTrace = env.GetEnvBool("TRACE", false) && IsDebug
InitTimeout = env.GetEnvDuation("INIT_TIMEOUT", 1*time.Minute)
ShortLinkPrefix = env.GetEnvString("SHORTLINK_PREFIX", "go")
ProxyHTTPAddr,

View File

@@ -103,7 +103,7 @@ func (state *state) InitFromFile(filename string) error {
}
func (state *state) Init(data []byte) error {
err := serialization.UnmarshalValidateYAML(data, &state.Config)
err := serialization.UnmarshalValidate(data, &state.Config, yaml.Unmarshal)
if err != nil {
return err
}
@@ -317,67 +317,50 @@ func (state *state) initProxmox() error {
return errs.Wait().Error()
}
func (state *state) storeProvider(p types.RouteProvider) {
state.providers.Store(p.String(), p)
}
func (state *state) loadRouteProviders() error {
providers := &state.Providers
providers := state.Providers
errs := gperr.NewGroup("route provider errors")
results := gperr.NewGroup("loaded route providers")
agentpool.RemoveAll()
numProviders := len(providers.Agents) + len(providers.Files) + len(providers.Docker)
providersCh := make(chan types.RouteProvider, numProviders)
// start providers concurrently
var providersConsumer sync.WaitGroup
providersConsumer.Go(func() {
for p := range providersCh {
if actual, loaded := state.providers.LoadOrStore(p.String(), p); loaded {
errs.Add(gperr.Errorf("provider %s already exists, first: %s, second: %s", p.String(), actual.GetType(), p.GetType()))
continue
}
state.storeProvider(p)
registerProvider := func(p types.RouteProvider) {
if actual, loaded := state.providers.LoadOrStore(p.String(), p); loaded {
errs.Addf("provider %s already exists, first: %s, second: %s", p.String(), actual.GetType(), p.GetType())
}
})
}
var providersProducer sync.WaitGroup
agentErrs := gperr.NewGroup("agent init errors")
for _, a := range providers.Agents {
providersProducer.Go(func() {
agentErrs.Go(func() error {
if err := a.Init(state.task.Context()); err != nil {
errs.Add(gperr.PrependSubject(a.String(), err))
return
return gperr.PrependSubject(a.String(), err)
}
agentpool.Add(a)
p := route.NewAgentProvider(a)
providersCh <- p
return nil
})
}
if err := agentErrs.Wait().Error(); err != nil {
errs.Add(err)
}
for _, a := range providers.Agents {
registerProvider(route.NewAgentProvider(a))
}
for _, filename := range providers.Files {
providersProducer.Go(func() {
p, err := route.NewFileProvider(filename)
if err != nil {
errs.Add(gperr.PrependSubject(filename, err))
} else {
providersCh <- p
}
})
p, err := route.NewFileProvider(filename)
if err != nil {
errs.Add(gperr.PrependSubject(filename, err))
return err
}
registerProvider(p)
}
for name, dockerCfg := range providers.Docker {
providersProducer.Go(func() {
providersCh <- route.NewDockerProvider(name, dockerCfg)
})
registerProvider(route.NewDockerProvider(name, dockerCfg))
}
providersProducer.Wait()
close(providersCh)
providersConsumer.Wait()
lenLongestName := 0
for k := range state.providers.Range {
if len(k) > lenLongestName {
@@ -386,18 +369,26 @@ func (state *state) loadRouteProviders() error {
}
// load routes concurrently
var providersLoader sync.WaitGroup
loadErrs := gperr.NewGroup("route load errors")
results := gperr.NewBuilder("loaded route providers")
resultsMu := sync.Mutex{}
for _, p := range state.providers.Range {
providersLoader.Go(func() {
loadErrs.Go(func() error {
if err := p.LoadRoutes(); err != nil {
errs.Add(err.Subject(p.String()))
return err.Subject(p.String())
}
resultsMu.Lock()
results.Addf("%-"+strconv.Itoa(lenLongestName)+"s %d routes", p.String(), p.NumRoutes())
resultsMu.Unlock()
return nil
})
}
providersLoader.Wait()
if err := loadErrs.Wait().Error(); err != nil {
errs.Add(err)
}
state.tmpLog.Info().Msg(results.Wait().String())
state.tmpLog.Info().Msg(results.String())
state.printRoutesByProvider(lenLongestName)
state.printState()
return errs.Wait().Error()

View File

@@ -4,6 +4,7 @@ import (
"regexp"
"github.com/go-playground/validator/v10"
"github.com/goccy/go-yaml"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/acl"
"github.com/yusing/godoxy/internal/autocert"
@@ -43,7 +44,7 @@ type (
func Validate(data []byte) gperr.Error {
var model Config
return serialization.UnmarshalValidateYAML(data, &model)
return serialization.UnmarshalValidate(data, &model, yaml.Unmarshal)
}
func DefaultConfig() Config {

View File

@@ -6,7 +6,7 @@ replace github.com/yusing/godoxy => ../..
require (
github.com/go-acme/lego/v4 v4.31.0
github.com/yusing/godoxy v0.25.0
github.com/yusing/godoxy v0.25.2
)
require (
@@ -23,12 +23,8 @@ require (
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -44,7 +40,7 @@ require (
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
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -53,7 +49,6 @@ require (
github.com/gotify/server/v2 v2.8.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
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -65,8 +60,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.106.1 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0 // 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
@@ -78,7 +73,6 @@ require (
github.com/sony/gobreaker v1.0.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/vultr/govultr/v3 v3.26.1 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusing/gointernals v0.1.16 // indirect
@@ -89,7 +83,6 @@ require (
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
@@ -98,8 +91,8 @@ require (
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/api v0.262.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
google.golang.org/api v0.263.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect

View File

@@ -37,18 +37,10 @@ github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZx
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -90,8 +82,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -119,8 +111,6 @@ github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00=
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -150,10 +140,10 @@ github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1 h1:+fx2mbWeR8XX/vidwpRMepJMtRIYQP44Iezm2oeObVM=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.1/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1 h1:GDhBiaIAm/QXLzHJ0ASDdY/6R/9w60+gk8lY5rgfxEQ=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.106.1/go.mod h1:EHScJdbM0gg5Is7e3C0ceRYAFMMsfP4Vf8sBRoxoTgk=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0 h1:eMzyN+jGJbxG4ut278uwIsUo9XacXc711lFjhKnaUso=
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.107.0/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0 h1:t34IpOa+8NfmjkU8bdWtYrLrmr346/FGhu8FlpJDQok=
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.107.0/go.mod h1:p95/OxVsdx71I2Qrck1GtIS87sRxcTRKXzUi5nWm9NY=
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=
@@ -188,11 +178,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCxJw=
github.com/vultr/govultr/v3 v3.26.1/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
@@ -221,8 +208,6 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
@@ -249,14 +234,14 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.262.0 h1:4B+3u8He2GwyN8St3Jhnd3XRHlIvc//sBmgHSp78oNY=
google.golang.org/api v0.262.0/go.mod h1:jNwmH8BgUBJ/VrUG6/lIl9YiildyLd09r9ZLHiQ6cGI=
google.golang.org/api v0.263.0 h1:UFs7qn8gInIdtk1ZA6eXRXp5JDAnS4x9VRsRVCeKdbk=
google.golang.org/api v0.263.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -14,7 +14,7 @@ import (
"unsafe"
"github.com/docker/cli/cli/connhelper"
"github.com/moby/moby/client"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/agentpool"
@@ -152,7 +152,7 @@ func NewClient(cfg types.DockerProviderConfig, unique ...bool) (*SharedClient, e
if agent.IsDockerHostAgent(host) {
a, ok := agentpool.Get(host)
if !ok {
panic(fmt.Errorf("agent %q not found", host))
return nil, fmt.Errorf("agent %q not found", host)
}
opt = []client.Opt{
client.WithHost(agent.DockerHost),
@@ -198,7 +198,9 @@ func NewClient(cfg types.DockerProviderConfig, unique ...bool) (*SharedClient, e
opt = append(opt, client.WithTLSClientConfig(cfg.TLS.CAFile, cfg.TLS.CertFile, cfg.TLS.KeyFile))
}
client, err := client.New(opt...)
opt = append(opt, client.WithAPIVersionNegotiation())
client, err := client.NewClientWithOpts(opt...)
if err != nil {
return nil, err
}

View File

@@ -11,9 +11,8 @@ import (
"strconv"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/agentpool"
"github.com/yusing/godoxy/internal/serialization"
@@ -99,18 +98,18 @@ func UpdatePorts(ctx context.Context, c *types.Container) error {
}
defer dockerClient.Close()
inspect, err := dockerClient.ContainerInspect(ctx, c.ContainerID, client.ContainerInspectOptions{})
inspect, err := dockerClient.ContainerInspect(ctx, c.ContainerID)
if err != nil {
return err
}
for port := range inspect.Container.Config.ExposedPorts {
proto, portStr := nat.SplitProtoPort(port.String())
for port := range inspect.Config.ExposedPorts {
proto, portStr := nat.SplitProtoPort(string(port))
portInt, _ := nat.ParsePort(portStr)
if portInt == 0 {
continue
}
c.PublicPortMapping[portInt] = container.PortSummary{
c.PublicPortMapping[portInt] = container.Port{
PublicPort: uint16(portInt), //nolint:gosec
PrivatePort: uint16(portInt), //nolint:gosec
Type: proto,
@@ -211,8 +210,8 @@ func setPrivateHostname(c *types.Container, helper containerHelper) {
}
if c.Network != "" {
v, hasNetwork := helper.NetworkSettings.Networks[c.Network]
if hasNetwork && v.IPAddress.IsValid() {
c.PrivateHostname = v.IPAddress.String()
if hasNetwork && v.IPAddress != "" {
c.PrivateHostname = v.IPAddress
return
}
var hasComposeNetwork bool
@@ -220,9 +219,9 @@ func setPrivateHostname(c *types.Container, helper containerHelper) {
if proj := DockerComposeProject(c); proj != "" {
newNetwork := fmt.Sprintf("%s_%s", proj, c.Network)
v, hasComposeNetwork = helper.NetworkSettings.Networks[newNetwork]
if hasComposeNetwork && v.IPAddress.IsValid() {
if hasComposeNetwork && v.IPAddress != "" {
c.Network = newNetwork // update network to the new one
c.PrivateHostname = v.IPAddress.String()
c.PrivateHostname = v.IPAddress
return
}
}
@@ -235,9 +234,9 @@ func setPrivateHostname(c *types.Container, helper containerHelper) {
}
// fallback to first network if no network is specified
for k, v := range helper.NetworkSettings.Networks {
if v.IPAddress.IsValid() {
if v.IPAddress != "" {
c.Network = k // update network to the first network
c.PrivateHostname = v.IPAddress.String()
c.PrivateHostname = v.IPAddress
return
}
}

View File

@@ -3,7 +3,7 @@ package docker
import (
"strings"
"github.com/moby/moby/api/types/container"
"github.com/docker/docker/api/types/container"
"github.com/yusing/ds/ordered"
"github.com/yusing/godoxy/internal/types"
strutils "github.com/yusing/goutils/strings"

View File

@@ -3,7 +3,7 @@ package docker
import (
"testing"
"github.com/moby/moby/api/types/container"
"github.com/docker/docker/api/types/container"
"github.com/yusing/godoxy/internal/types"
expect "github.com/yusing/goutils/testing"
)

View File

@@ -3,12 +3,12 @@ package docker
import (
"context"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/yusing/godoxy/internal/types"
)
var listOptions = client.ContainerListOptions{
var listOptions = container.ListOptions{
// created|restarting|running|removing|paused|exited|dead
// Filters: filters.NewArgs(
// filters.Arg("status", "created"),
@@ -31,7 +31,7 @@ func ListContainers(ctx context.Context, dockerCfg types.DockerProviderConfig) (
if err != nil {
return nil, err
}
return containers.Items, nil
return containers, nil
}
func IsErrConnectionFailed(err error) bool {

View File

@@ -2,13 +2,12 @@ package healthcheck
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/bytedance/sonic"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/types"
httputils "github.com/yusing/goutils/http"
@@ -44,7 +43,7 @@ func Docker(ctx context.Context, state *DockerHealthcheckState, timeout time.Dur
defer cancel()
// the actual inspect response is intercepted and returned as RequestInterceptedError
_, err := state.client.ContainerInspect(ctx, state.containerId, client.ContainerInspectOptions{})
_, err := state.client.ContainerInspect(ctx, state.containerId)
var interceptedErr *httputils.RequestInterceptedError
if !httputils.AsRequestInterceptedError(err, &interceptedErr) {
@@ -106,7 +105,7 @@ func interceptDockerInspectResponse(resp *http.Response) (intercepted bool, err
}
var state container.State
err = sonic.Unmarshal(body, &state)
err = json.Unmarshal(body, &state)
release(body)
if err != nil {
return false, err

View File

@@ -76,8 +76,11 @@ func H2C(ctx context.Context, url *url.URL, method, path string, timeout time.Du
setCommonHeaders(req.Header.Set)
client := *h2cClient
client.Timeout = timeout
start := time.Now()
resp, err := h2cClient.Do(req)
resp, err := client.Do(req)
lat := time.Since(start)
if resp != nil {

View File

@@ -2,12 +2,12 @@ package iconlist
import (
"context"
"encoding/json"
"net/http"
"slices"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
@@ -55,20 +55,20 @@ func init() {
func InitCache() {
m := make(IconMap)
err := serialization.LoadJSONIfExist(common.IconListCachePath, &m)
err := serialization.LoadFileIfExist(common.IconListCachePath, &m, json.Unmarshal)
if err != nil {
// backward compatible
oldFormat := struct {
Icons IconMap
LastUpdate time.Time
}{}
err = serialization.LoadJSONIfExist(common.IconListCachePath, &oldFormat)
err = serialization.LoadFileIfExist(common.IconListCachePath, &oldFormat, json.Unmarshal)
if err != nil {
log.Error().Err(err).Msg("failed to load icons")
} else {
m = oldFormat.Icons
// store it to disk immediately
_ = serialization.SaveJSON(common.IconListCachePath, &m, 0o644)
_ = serialization.SaveFile(common.IconListCachePath, &m, 0o644, json.Marshal)
}
} else if len(m) > 0 {
log.Info().
@@ -84,7 +84,7 @@ func InitCache() {
task.OnProgramExit("save_icons_cache", func() {
icons := iconsCache.Load()
_ = serialization.SaveJSON(common.IconListCachePath, &icons, 0o644)
_ = serialization.SaveFile(common.IconListCachePath, &icons, 0o644, json.Marshal)
})
go backgroundUpdateIcons()
@@ -105,7 +105,7 @@ func backgroundUpdateIcons() {
// swap old cache with new cache
iconsCache.Store(newCache)
// save it to disk
err := serialization.SaveJSON(common.IconListCachePath, &newCache, 0o644)
err := serialization.SaveFile(common.IconListCachePath, &newCache, 0o644, json.Marshal)
if err != nil {
log.Warn().Err(err).Msg("failed to save icons")
}
@@ -270,7 +270,7 @@ func UpdateWalkxCodeIcons(m IconMap) error {
}
data := make(map[string][]string)
err = sonic.Unmarshal(body, &data)
err = json.Unmarshal(body, &data)
release(body)
if err != nil {
return err
@@ -349,7 +349,7 @@ func UpdateSelfhstIcons(m IconMap) error {
}
data := make([]SelfhStIcon, 0)
err = sonic.Unmarshal(body, &data) //nolint:musttag
err = json.Unmarshal(body, &data) //nolint:musttag
release(body)
if err != nil {
return err

View File

@@ -2,11 +2,11 @@ package qbittorrent
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"github.com/bytedance/sonic"
"github.com/yusing/godoxy/internal/homepage/widgets"
gperr "github.com/yusing/goutils/errs"
)
@@ -59,7 +59,7 @@ func jsonRequest[T any](ctx context.Context, client *Client, endpoint string, qu
}
defer resp.Body.Close()
err = sonic.ConfigDefault.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
if err != nil {
return result, err
}

View File

@@ -2,11 +2,10 @@ package qbittorrent
import (
"context"
"encoding/json"
"net/url"
"strconv"
"time"
"github.com/bytedance/sonic"
)
const endpointLogs = "/api/v2/log/main"
@@ -45,7 +44,7 @@ func (l *LogEntry) Level() string {
}
func (l *LogEntry) MarshalJSON() ([]byte, error) {
return sonic.Marshal(map[string]any{
return json.Marshal(map[string]any{
"id": l.ID,
"timestamp": l.Timestamp,
"level": l.Level(),

View File

@@ -1,10 +1,10 @@
package idlewatcher
import (
"encoding/json"
"iter"
"strconv"
"github.com/bytedance/sonic"
strutils "github.com/yusing/goutils/strings"
)
@@ -14,7 +14,7 @@ type watcherDebug struct {
func (w watcherDebug) MarshalJSON() ([]byte, error) {
state := w.state.Load()
return sonic.Marshal(map[string]any{
return json.Marshal(map[string]any{
"name": w.Name(),
"state": map[string]string{
"status": string(state.status),

View File

@@ -1,11 +1,10 @@
package idlewatcher
import (
"encoding/json"
"fmt"
"io"
"time"
"github.com/bytedance/sonic"
)
type WakeEvent struct {
@@ -40,7 +39,7 @@ func (w *Watcher) newWakeEvent(eventType WakeEventType, message string, err erro
}
func (e *WakeEvent) WriteSSE(w io.Writer) error {
data, err := sonic.Marshal(e)
data, err := json.Marshal(e)
if err != nil {
return err
}

View File

@@ -3,8 +3,7 @@ package provider
import (
"context"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/docker/docker/api/types/container"
"github.com/yusing/godoxy/internal/docker"
idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types"
"github.com/yusing/godoxy/internal/types"
@@ -18,7 +17,7 @@ type DockerProvider struct {
containerID string
}
var startOptions = client.ContainerStartOptions{}
var startOptions = container.StartOptions{}
func NewDockerProvider(dockerCfg types.DockerProviderConfig, containerID string) (idlewatcher.Provider, error) {
client, err := docker.NewClient(dockerCfg)
@@ -33,41 +32,34 @@ func NewDockerProvider(dockerCfg types.DockerProviderConfig, containerID string)
}
func (p *DockerProvider) ContainerPause(ctx context.Context) error {
_, err := p.client.ContainerPause(ctx, p.containerID, client.ContainerPauseOptions{})
return err
return p.client.ContainerPause(ctx, p.containerID)
}
func (p *DockerProvider) ContainerUnpause(ctx context.Context) error {
_, err := p.client.ContainerUnpause(ctx, p.containerID, client.ContainerUnpauseOptions{})
return err
return p.client.ContainerUnpause(ctx, p.containerID)
}
func (p *DockerProvider) ContainerStart(ctx context.Context) error {
_, err := p.client.ContainerStart(ctx, p.containerID, startOptions)
return err
return p.client.ContainerStart(ctx, p.containerID, startOptions)
}
func (p *DockerProvider) ContainerStop(ctx context.Context, signal types.ContainerSignal, timeout int) error {
_, err := p.client.ContainerStop(ctx, p.containerID, client.ContainerStopOptions{
return p.client.ContainerStop(ctx, p.containerID, container.StopOptions{
Signal: string(signal),
Timeout: &timeout,
})
return err
}
func (p *DockerProvider) ContainerKill(ctx context.Context, signal types.ContainerSignal) error {
_, err := p.client.ContainerKill(ctx, p.containerID, client.ContainerKillOptions{
Signal: string(signal),
})
return err
return p.client.ContainerKill(ctx, p.containerID, string(signal))
}
func (p *DockerProvider) ContainerStatus(ctx context.Context) (idlewatcher.ContainerStatus, error) {
status, err := p.client.ContainerInspect(ctx, p.containerID, client.ContainerInspectOptions{})
status, err := p.client.ContainerInspect(ctx, p.containerID)
if err != nil {
return idlewatcher.ContainerStatusError, err
}
switch status.Container.State.Status {
switch status.State.Status {
case container.StateRunning:
return idlewatcher.ContainerStatusRunning, nil
case container.StateExited, container.StateDead, container.StateRestarting:
@@ -75,12 +67,12 @@ func (p *DockerProvider) ContainerStatus(ctx context.Context) (idlewatcher.Conta
case container.StatePaused:
return idlewatcher.ContainerStatusPaused, nil
}
return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(string(status.Container.State.Status))
return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(status.State.Status)
}
func (p *DockerProvider) Watch(ctx context.Context) (eventCh <-chan watcher.Event, errCh <-chan gperr.Error) {
return p.watcher.EventsWithOptions(ctx, watcher.DockerListOptions{
Filters: watcher.NewDockerFilters(
Filters: watcher.NewDockerFilter(
watcher.DockerFilterContainer,
watcher.DockerFilterContainerNameID(p.containerID),
watcher.DockerFilterStart,

View File

@@ -26,6 +26,10 @@ const proxmoxStateCheckInterval = 1 * time.Second
var ErrNodeNotFound = gperr.New("node not found in pool")
func NewProxmoxProvider(ctx context.Context, nodeName string, vmid int) (idlewatcher.Provider, error) {
if nodeName == "" || vmid == 0 {
return nil, gperr.New("node name and vmid are required")
}
node, ok := proxmox.Nodes.Get(nodeName)
if !ok {
return nil, ErrNodeNotFound.Subject(nodeName).

View File

@@ -1 +0,0 @@
package idlewatcher

View File

@@ -261,7 +261,7 @@ func loadNS[T store](ns namespace) T {
}
defer file.Close()
if err := sonic.ConfigDefault.NewDecoder(file).Decode(&store); err != nil {
if err := json.NewDecoder(file).Decode(&store); err != nil {
log.Err(err).Msg("failed to decode store")
}
@@ -316,7 +316,7 @@ type MapStore[VT any] struct {
```go
func (s MapStore[VT]) MarshalJSON() ([]byte, error) {
return sonic.Marshal(xsync.ToPlainMap(s.Map))
return json.Marshal(xsync.ToPlainMap(s.Map))
}
```
@@ -325,7 +325,7 @@ func (s MapStore[VT]) MarshalJSON() ([]byte, error) {
```go
func (s *MapStore[VT]) UnmarshalJSON(data []byte) error {
tmp := make(map[string]VT)
if err := sonic.Unmarshal(data, &tmp); err != nil {
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
s.Map = xsync.NewMap[string, VT](xsync.WithPresize(len(tmp)))
@@ -349,7 +349,7 @@ The jsonstore package integrates with:
Errors are logged but don't prevent store usage:
```go
if err := sonic.Unmarshal(data, &tmp); err != nil {
if err := json.Unmarshal(data, &tmp); err != nil {
log.Err(err).
Str("path", path).
Msg("failed to load store")

View File

@@ -6,7 +6,6 @@ import (
"path/filepath"
"reflect"
"github.com/bytedance/sonic"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
@@ -66,7 +65,7 @@ func loadNS[T store](ns namespace) T {
}
} else {
defer file.Close()
if err := sonic.ConfigDefault.NewDecoder(file).Decode(&store); err != nil {
if err := json.NewDecoder(file).Decode(&store); err != nil {
log.Err(err).
Str("path", path).
Msg("failed to load store")
@@ -83,7 +82,8 @@ func loadNS[T store](ns namespace) T {
func save() error {
errs := gperr.NewBuilder("failed to save data stores")
for ns, store := range stores {
if err := serialization.SaveJSON(filepath.Join(storesPath, string(ns)+".json"), &store, 0o644); err != nil {
path := filepath.Join(storesPath, string(ns)+".json")
if err := serialization.SaveFile(path, &store, 0o644, json.Marshal); err != nil {
errs.Add(err)
}
}
@@ -113,12 +113,12 @@ func (s *MapStore[VT]) Initialize() {
}
func (s MapStore[VT]) MarshalJSON() ([]byte, error) {
return sonic.Marshal(xsync.ToPlainMap(s.Map))
return json.Marshal(xsync.ToPlainMap(s.Map))
}
func (s *MapStore[VT]) UnmarshalJSON(data []byte) error {
tmp := make(map[string]VT)
if err := sonic.Unmarshal(data, &tmp); err != nil {
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
s.Map = xsync.NewMap[string, VT](xsync.WithPresize(len(tmp)))
@@ -134,10 +134,10 @@ func (obj *ObjectStore[Ptr]) Initialize() {
}
func (obj ObjectStore[Ptr]) MarshalJSON() ([]byte, error) {
return sonic.Marshal(obj.ptr)
return json.Marshal(obj.ptr)
}
func (obj *ObjectStore[Ptr]) UnmarshalJSON(data []byte) error {
obj.Initialize()
return sonic.Unmarshal(data, obj.ptr)
return json.Unmarshal(data, obj.ptr)
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/rs/zerolog"
"github.com/yusing/godoxy/internal/common"
"github.com/rs/zerolog/diode"
zerologlog "github.com/rs/zerolog/log"
)
@@ -68,7 +69,13 @@ func fmtMessage(msg string) string {
return sb.String()
}
func multiLevelWriter(out ...io.Writer) io.Writer {
func diodeMultiWriter(out ...io.Writer) io.Writer {
return diode.NewWriter(multiWriter(out...), 1024, 0, func(missed int) {
zerologlog.Warn().Int("missed", missed).Msg("missed log messages")
})
}
func multiWriter(out ...io.Writer) io.Writer {
if len(out) == 0 {
return os.Stdout
}
@@ -80,7 +87,7 @@ func multiLevelWriter(out ...io.Writer) io.Writer {
func NewLogger(out ...io.Writer) zerolog.Logger {
writer := zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
w.Out = multiLevelWriter(out...)
w.Out = diodeMultiWriter(out...)
w.TimeFormat = timeFmt
w.FormatMessage = func(msgI any) string { // pad spaces for each line
if msgI == nil {
@@ -94,7 +101,7 @@ func NewLogger(out ...io.Writer) zerolog.Logger {
func NewLoggerWithFixedLevel(lvl zerolog.Level, out ...io.Writer) zerolog.Logger {
writer := zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
w.Out = multiLevelWriter(out...)
w.Out = diodeMultiWriter(out...)
w.TimeFormat = timeFmt
w.FormatMessage = func(msgI any) string { // pad spaces for each line
if msgI == nil {

View File

@@ -3,8 +3,6 @@ package period
import (
"encoding/json"
"time"
"github.com/bytedance/sonic"
)
type Entries[T any] struct {
@@ -75,7 +73,7 @@ type entriesJSON[T any] struct {
}
func (e *Entries[T]) MarshalJSON() ([]byte, error) {
return sonic.Marshal(entriesJSON[T]{
return json.Marshal(entriesJSON[T]{
Entries: e.Get(),
Interval: e.interval,
})

View File

@@ -10,7 +10,6 @@ import (
"sync"
"time"
"github.com/bytedance/sonic"
"github.com/rs/zerolog/log"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/synk"
@@ -98,7 +97,7 @@ func (p *Poller[T, AggregateT]) save() error {
}
defer f.Close()
err = sonic.ConfigDefault.NewEncoder(f).Encode(p.period)
err = json.NewEncoder(f).Encode(p.period)
if err != nil {
return err
}

View File

@@ -6,7 +6,6 @@ import (
"reflect"
"testing"
"github.com/bytedance/sonic"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/mem"
"github.com/shirou/gopsutil/v4/net"
@@ -82,12 +81,12 @@ var (
func TestSystemInfo(t *testing.T) {
// Test marshaling
data, err := sonic.Marshal(testInfo)
data, err := json.Marshal(testInfo)
expect.NoError(t, err)
// Test unmarshaling back
var decoded SystemInfo
err = sonic.Unmarshal(data, &decoded)
err = json.Unmarshal(data, &decoded)
expect.NoError(t, err)
// Compare original and decoded
@@ -127,7 +126,7 @@ func TestSerialize(t *testing.T) {
for _, query := range allQueries {
t.Run(string(query), func(t *testing.T) {
_, result := aggregate(entries, url.Values{"aggregate": []string{string(query)}})
s, err := sonic.Marshal(result)
s, err := json.Marshal(result)
expect.NoError(t, err)
var v []map[string]any
expect.NoError(t, json.Unmarshal(s, &v))

View File

@@ -2,11 +2,11 @@ package uptime
import (
"context"
"encoding/json"
"net/url"
"slices"
"time"
"github.com/bytedance/sonic"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/yusing/godoxy/internal/metrics/period"
metricsutils "github.com/yusing/godoxy/internal/metrics/utils"
@@ -47,7 +47,7 @@ func getStatuses(ctx context.Context, _ StatusByAlias) (StatusByAlias, error) {
}
func (s *Status) MarshalJSON() ([]byte, error) {
return sonic.Marshal(map[string]any{
return json.Marshal(map[string]any{
"status": s.Status.String(),
"latency": s.Latency,
"timestamp": s.Timestamp,
@@ -149,5 +149,5 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated {
}
func (result Aggregated) MarshalJSON() ([]byte, error) {
return sonic.Marshal([]RouteAggregate(result))
return json.Marshal([]RouteAggregate(result))
}

View File

@@ -3,6 +3,7 @@ package captcha
import (
"bytes"
"context"
"encoding/json"
"errors"
"net"
"net/http"
@@ -11,7 +12,6 @@ import (
_ "embed"
"github.com/bytedance/sonic"
gperr "github.com/yusing/goutils/errs"
)
@@ -70,7 +70,7 @@ func (p *HcaptchaProvider) Verify(r *http.Request) error {
Success bool `json:"success"`
Error []string `json:"error-codes"`
}
if err := sonic.ConfigDefault.NewDecoder(resp.Body).Decode(&respData); err != nil {
if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil {
return err
}

View File

@@ -1,12 +1,12 @@
package middleware
import (
"encoding/json"
"maps"
"net/http"
"reflect"
"sort"
"github.com/bytedance/sonic"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/serialization"
@@ -152,7 +152,7 @@ func (m *Middleware) MarshalJSON() ([]byte, error) {
commonOptions
any
}
return sonic.MarshalIndent(map[string]any{
return json.MarshalIndent(map[string]any{
"name": m.name,
"options": allOptions{
commonOptions: m.commonOptions,

View File

@@ -3,12 +3,12 @@ package middleware
import (
"bytes"
_ "embed"
"encoding/json"
"io"
"maps"
"net/http"
"net/http/httptest"
"github.com/bytedance/sonic"
"github.com/yusing/godoxy/internal/common"
nettypes "github.com/yusing/godoxy/internal/net/types"
gperr "github.com/yusing/goutils/errs"
@@ -24,7 +24,7 @@ func init() {
return
}
tmp := map[string]string{}
err := sonic.Unmarshal(testHeadersRaw, &tmp)
err := json.Unmarshal(testHeadersRaw, &tmp)
if err != nil {
panic(err)
}

View File

@@ -1,9 +1,8 @@
package nettypes
import (
"encoding/json"
urlPkg "net/url"
"github.com/bytedance/sonic"
)
type URL struct {
@@ -47,7 +46,7 @@ func (u *URL) MarshalJSON() (text []byte, err error) {
if u == nil {
return []byte("null"), nil
}
return sonic.Marshal(u.URL.String())
return json.Marshal(u.URL.String())
}
func (u *URL) Equals(other *URL) bool {

View File

@@ -2,9 +2,9 @@ package notif
import (
"bytes"
"encoding/json"
"strings"
"github.com/bytedance/sonic"
gperr "github.com/yusing/goutils/errs"
)
@@ -69,7 +69,7 @@ func (f FieldsBody) Format(format LogFormat) ([]byte, error) {
}
return msg.Bytes(), nil
case LogFormatRawJSON:
return sonic.Marshal(f)
return json.Marshal(f)
}
return f.Format(LogFormatMarkdown)
}
@@ -87,7 +87,7 @@ func (l ListBody) Format(format LogFormat) ([]byte, error) {
}
return msg.Bytes(), nil
case LogFormatRawJSON:
return sonic.Marshal(l)
return json.Marshal(l)
}
return l.Format(LogFormatMarkdown)
}
@@ -97,7 +97,7 @@ func (m MessageBody) Format(format LogFormat) ([]byte, error) {
case LogFormatPlain, LogFormatMarkdown:
return []byte(m), nil
case LogFormatRawJSON:
return sonic.Marshal(m)
return json.Marshal(m)
}
return []byte(m), nil
}
@@ -105,7 +105,7 @@ func (m MessageBody) Format(format LogFormat) ([]byte, error) {
func (m MessageBodyBytes) Format(format LogFormat) ([]byte, error) {
switch format {
case LogFormatRawJSON:
return sonic.Marshal(string(m))
return json.Marshal(string(m))
}
return m, nil
}
@@ -113,7 +113,7 @@ func (m MessageBodyBytes) Format(format LogFormat) ([]byte, error) {
func (e errorBody) Format(format LogFormat) ([]byte, error) {
switch format {
case LogFormatRawJSON:
return sonic.Marshal(e.Error)
return json.Marshal(e.Error)
case LogFormatPlain:
return gperr.Plain(e.Error), nil
case LogFormatMarkdown:

View File

@@ -1,10 +1,10 @@
package notif
import (
"encoding/json"
"fmt"
"io"
"github.com/bytedance/sonic"
"github.com/gotify/server/v2/model"
"github.com/rs/zerolog"
gperr "github.com/yusing/goutils/errs"
@@ -65,7 +65,7 @@ func (client *GotifyClient) MarshalMessage(logMsg *LogMessage) ([]byte, error) {
}
}
data, err := sonic.Marshal(msg)
data, err := json.Marshal(msg)
if err != nil {
return nil, err
}
@@ -76,7 +76,7 @@ func (client *GotifyClient) MarshalMessage(logMsg *LogMessage) ([]byte, error) {
// fmtError implements Provider.
func (client *GotifyClient) fmtError(respBody io.Reader) error {
var errm model.Error
err := sonic.ConfigDefault.NewDecoder(respBody).Decode(&errm)
err := json.NewDecoder(respBody).Decode(&errm)
if err != nil {
return fmt.Errorf("failed to decode err response: %w", err)
}

View File

@@ -2,11 +2,11 @@ package notif
import (
_ "embed"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/bytedance/sonic"
gperr "github.com/yusing/goutils/errs"
)
@@ -99,7 +99,7 @@ func (webhook *Webhook) fmtError(respBody io.Reader) error {
}
func (webhook *Webhook) MarshalMessage(logMsg *LogMessage) ([]byte, error) {
title, err := sonic.Marshal(logMsg.Title)
title, err := json.Marshal(logMsg.Title)
if err != nil {
return nil, err
}
@@ -118,7 +118,7 @@ func (webhook *Webhook) MarshalMessage(logMsg *LogMessage) ([]byte, error) {
return nil, err
}
if webhook.MIMEType == MimeTypeJSON {
message, err = sonic.Marshal(string(message))
message, err = json.Marshal(string(message))
if err != nil {
return nil, err
}
@@ -147,5 +147,5 @@ func validateJSONPayload(payload string) bool {
"$color", "",
)
payload = replacer.Replace(payload)
return sonic.Valid([]byte(payload))
return json.Valid([]byte(payload))
}

View File

@@ -52,11 +52,11 @@ graph TD
```go
type Config struct {
URL string `json:"url" validate:"required,url"`
Username string `json:"username" validate:"required_without=TokenID Secret"`
Password strutils.Redacted `json:"password" validate:"required_without=TokenID Secret"`
Realm string `json:"realm" validate:"required_without=TokenID Secret"`
TokenID string `json:"token_id" validate:"required_without=Username Password"`
Secret strutils.Redacted `json:"secret" validate:"required_without=Username Password"`
Username string `json:"username" validate:"required_without_all=TokenID Secret"`
Password strutils.Redacted `json:"password" validate:"required_without_all=TokenID Secret"`
Realm string `json:"realm"`
TokenID string `json:"token_id" validate:"required_without_all=Username Password"`
Secret strutils.Redacted `json:"secret" validate:"required_without_all=Username Password"`
NoTLSVerify bool `json:"no_tls_verify"`
client *Client
@@ -70,6 +70,7 @@ type Client struct {
*proxmox.Client
*proxmox.Cluster
Version *proxmox.Version
BaseURL *url.URL
// id -> resource; id: lxc/<vmid> or qemu/<vmid>
resources map[string]*VMResource
resourcesMu sync.RWMutex
@@ -79,6 +80,9 @@ type VMResource struct {
*proxmox.ClusterResource
IPs []net.IP
}
// NewClient creates a new Proxmox client.
func NewClient(baseUrl string, opts ...proxmox.Option) *Client
```
### Node
@@ -97,10 +101,11 @@ var Nodes = pool.New[*Node]("proxmox_nodes")
```go
type NodeConfig struct {
Node string `json:"node" validate:"required"`
VMID int `json:"vmid" validate:"required"`
VMName string `json:"vmname,omitempty"`
Service string `json:"service,omitempty"`
Node string `json:"node" validate:"required"`
VMID *int `json:"vmid"` // nil: auto discover; 0: node-level route; >0: lxc/qemu resource route
VMName string `json:"vmname,omitempty"`
Services []string `json:"services,omitempty" aliases:"service"`
Files []string `json:"files,omitempty" aliases:"file"`
}
```
@@ -119,6 +124,9 @@ func (c *Config) Client() *Client
### Client Operations
```go
// NewClient creates a new Proxmox client.
func NewClient(baseUrl string, opts ...proxmox.Option) *Client
// UpdateClusterInfo fetches cluster info and discovers nodes.
func (c *Client) UpdateClusterInfo(ctx context.Context) error
@@ -136,6 +144,15 @@ func (c *Client) ReverseLookupNode(hostname string, ip net.IP, alias string) str
// NumNodes returns the number of nodes in the cluster.
func (c *Client) NumNodes() int
// Key returns the cluster ID.
func (c *Client) Key() string
// Name returns the cluster name.
func (c *Client) Name() string
// MarshalJSON returns the cluster info as JSON.
func (c *Client) MarshalJSON() ([]byte, error)
```
### Node Operations
@@ -144,17 +161,29 @@ func (c *Client) NumNodes() int
// AvailableNodeNames returns all available node names as a comma-separated string.
func AvailableNodeNames() string
// NewNode creates a new node.
func NewNode(client *Client, name, id string) *Node
// Node.Client returns the Proxmox client.
func (n *Node) Client() *Client
// Node.Get performs a GET request on the node.
func (n *Node) Get(ctx context.Context, path string, v any) error
// Node.Key returns the node name.
func (n *Node) Key() string
// Node.Name returns the node name.
func (n *Node) Name() string
// NodeCommand executes a command on the node and streams output.
func (n *Node) NodeCommand(ctx context.Context, command string) (io.ReadCloser, error)
// NodeJournalctl streams journalctl output from the node.
func (n *Node) NodeJournalctl(ctx context.Context, service string, limit int) (io.ReadCloser, error)
func (n *Node) NodeJournalctl(ctx context.Context, services []string, limit int) (io.ReadCloser, error)
// NodeTail streams tail output for the given file.
func (n *Node) NodeTail(ctx context.Context, files []string, limit int) (io.ReadCloser, error)
```
## Usage
@@ -275,7 +304,35 @@ func (node *Node) LXCStats(ctx context.Context, vmid int, stream bool) (io.ReadC
func (node *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.ReadCloser, error)
// LXCJournalctl streams journalctl output for a container service.
func (node *Node) LXCJournalctl(ctx context.Context, vmid int, service string, limit int) (io.ReadCloser, error)
// On non-systemd systems, it falls back to tailing /var/log/messages.
func (node *Node) LXCJournalctl(ctx context.Context, vmid int, services []string, limit int) (io.ReadCloser, error)
// LXCTail streams tail output for the given file.
func (node *Node) LXCTail(ctx context.Context, vmid int, files []string, limit int) (io.ReadCloser, error)
```
## Node Stats
```go
type NodeStats struct {
KernelVersion string `json:"kernel_version"`
PVEVersion string `json:"pve_version"`
CPUUsage string `json:"cpu_usage"`
CPUModel string `json:"cpu_model"`
MemUsage string `json:"mem_usage"`
MemTotal string `json:"mem_total"`
MemPct string `json:"mem_pct"`
RootFSUsage string `json:"rootfs_usage"`
RootFSTotal string `json:"rootfs_total"`
RootFSPct string `json:"rootfs_pct"`
Uptime string `json:"uptime"`
LoadAvg1m string `json:"load_avg_1m"`
LoadAvg5m string `json:"load_avg_5m"`
LoadAvg15m string `json:"load_avg_15m"`
}
// NodeStats streams node statistics like docker stats.
func (n *Node) NodeStats(ctx context.Context, stream bool) (io.ReadCloser, error)
```
## Data Flow
@@ -453,6 +510,12 @@ var (
)
```
| Error | Description |
| --------------------- | --------------------------------------------------------------------- |
| `ErrResourceNotFound` | Resource not found in cluster |
| `ErrNoResources` | No resources available |
| `ErrNoSession` | No session for WebSocket operations (requires username/password auth) |
## Performance Considerations
- Cluster info fetched once on init
@@ -463,10 +526,26 @@ var (
- Per-operation API calls with 3-second timeout
- WebSocket connections properly closed to prevent goroutine leaks
## Command Validation
Commands executed via WebSocket are validated to prevent command injection. Invalid characters include:
```
& | $ ; ' " ` $( ${ < >
```
Services and files passed to `journalctl` and `tail` commands are automatically validated.
## Constants
```go
const ResourcePollInterval = 3 * time.Second
const SessionRefreshInterval = 1 * time.Minute
const NodeStatsPollInterval = time.Second
```
The `ResourcePollInterval` constant controls how often resources are updated in the background loop.
| Constant | Default | Description |
| ------------------------ | ------- | ---------------------------------- |
| `ResourcePollInterval` | 3s | How often VM resources are updated |
| `SessionRefreshInterval` | 1m | How often sessions are refreshed |
| `NodeStatsPollInterval` | 1s | How often node stats are streamed |

View File

@@ -2,18 +2,18 @@ package proxmox
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"github.com/bytedance/sonic"
"github.com/luthermonson/go-proxmox"
"github.com/rs/zerolog/log"
"golang.org/x/sync/errgroup"
)
@@ -21,6 +21,7 @@ type Client struct {
*proxmox.Client
*proxmox.Cluster
Version *proxmox.Version
BaseURL *url.URL
// id -> resource; id: lxc/<vmid> or qemu/<vmid>
resources map[string]*VMResource
resourcesMu sync.RWMutex
@@ -44,6 +45,11 @@ func NewClient(baseUrl string, opts ...proxmox.Option) *Client {
}
func (c *Client) UpdateClusterInfo(ctx context.Context) (err error) {
baseURL, err := url.Parse(c.Client.GetBaseURL())
if err != nil {
return err
}
c.BaseURL = baseURL
c.Version, err = c.Client.Version(ctx)
if err != nil {
return err
@@ -65,6 +71,9 @@ func (c *Client) UpdateClusterInfo(ctx context.Context) (err error) {
}
func (c *Client) UpdateResources(ctx context.Context) error {
if c.Cluster == nil {
return errors.New("cluster not initialized, call UpdateClusterInfo first")
}
resourcesSlice, err := c.Cluster.Resources(ctx, "vm")
if err != nil {
return err
@@ -110,7 +119,6 @@ func (c *Client) UpdateResources(ctx context.Context) error {
c.resources[resource.ID] = vmResources[i]
}
c.resourcesMu.Unlock()
log.Debug().Str("cluster", c.Cluster.Name).Msgf("[proxmox] updated %d resources", len(c.resources))
return nil
}
@@ -193,7 +201,7 @@ func (c *Client) Name() string {
}
func (c *Client) MarshalJSON() ([]byte, error) {
return sonic.Marshal(map[string]any{
return json.Marshal(map[string]any{
"version": c.Version,
"cluster": map[string]any{
"name": c.Cluster.Name,

View File

@@ -0,0 +1,60 @@
package proxmox
import (
"fmt"
"strings"
)
// checkValidInput checks if the input contains invalid characters.
//
// The characters are: & | $ ; ' " ` $( ${ < >
// These characters are used in the command line to escape the input or to expand variables.
// We need to check if the input contains these characters and return an error if it does.
// This is to prevent command injection.
func checkValidInput(input string) error {
if strings.ContainsAny(input, "&|$;'\"`<>") {
return fmt.Errorf("input contains invalid characters: %q", input)
}
if strings.Contains(input, "$(") {
return fmt.Errorf("input contains $(: %q", input)
}
if strings.Contains(input, "${") {
return fmt.Errorf("input contains ${: %q", input)
}
return nil
}
func formatTail(files []string, limit int) (string, error) {
for _, file := range files {
if err := checkValidInput(file); err != nil {
return "", err
}
}
var command strings.Builder
command.WriteString("tail -f -q ")
for _, file := range files {
fmt.Fprintf(&command, " %q ", file)
}
if limit > 0 {
fmt.Fprintf(&command, " -n %d", limit)
}
// try --retry first, if it fails, try the command again
return fmt.Sprintf("sh -c '%s --retry 2>/dev/null || %s'", command.String(), command.String()), nil
}
func formatJournalctl(services []string, limit int) (string, error) {
for _, service := range services {
if err := checkValidInput(service); err != nil {
return "", err
}
}
var command strings.Builder
command.WriteString("journalctl -f")
for _, service := range services {
fmt.Fprintf(&command, " -u %q ", service)
}
if limit > 0 {
fmt.Fprintf(&command, " -n %d", limit)
}
return command.String(), nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"errors"
"math"
"net/http"
"strings"
"time"
@@ -18,12 +19,12 @@ import (
type Config struct {
URL string `json:"url" validate:"required,url"`
Username string `json:"username" validate:"required_without=TokenID Secret"`
Password strutils.Redacted `json:"password" validate:"required_without=TokenID Secret"`
Realm string `json:"realm" validate:"required_without=TokenID Secret"`
Username string `json:"username" validate:"required_without_all=TokenID Secret"`
Password strutils.Redacted `json:"password" validate:"required_without_all=TokenID Secret"`
Realm string `json:"realm"` // default is "pam"
TokenID string `json:"token_id" validate:"required_without=Username Password"`
Secret strutils.Redacted `json:"secret" validate:"required_without=Username Password"`
TokenID string `json:"token_id" validate:"required_without_all=Username Password"`
Secret strutils.Redacted `json:"secret" validate:"required_without_all=Username Password"`
NoTLSVerify bool `json:"no_tls_verify" yaml:"no_tls_verify,omitempty"`
@@ -31,6 +32,7 @@ type Config struct {
}
const ResourcePollInterval = 3 * time.Second
const SessionRefreshInterval = 1 * time.Minute
// NodeStatsPollInterval controls how often node stats are streamed when streaming is enabled.
const NodeStatsPollInterval = time.Second
@@ -65,6 +67,9 @@ func (c *Config) Init(ctx context.Context) gperr.Error {
}
useCredentials := false
if c.Username != "" && c.Password != "" {
if c.Realm == "" {
c.Realm = "pam"
}
opts = append(opts, proxmox.WithCredentials(&proxmox.Credentials{
Username: c.Username,
Password: c.Password.String(),
@@ -93,16 +98,6 @@ func (c *Config) Init(ctx context.Context) gperr.Error {
return gperr.New("failed to fetch proxmox cluster info").With(err)
}
go c.updateResourcesLoop(ctx)
return nil
}
func (c *Config) updateResourcesLoop(ctx context.Context) {
ticker := time.NewTicker(ResourcePollInterval)
defer ticker.Stop()
log.Trace().Str("cluster", c.client.Cluster.Name).Msg("[proxmox] starting resources update loop")
{
reqCtx, reqCtxCancel := context.WithTimeout(ctx, ResourcePollInterval)
err := c.client.UpdateResources(reqCtx)
@@ -112,6 +107,17 @@ func (c *Config) updateResourcesLoop(ctx context.Context) {
}
}
go c.updateResourcesLoop(ctx)
go c.refreshSessionLoop(ctx)
return nil
}
func (c *Config) updateResourcesLoop(ctx context.Context) {
ticker := time.NewTicker(ResourcePollInterval)
defer ticker.Stop()
log.Trace().Str("cluster", c.client.Cluster.Name).Msg("[proxmox] starting resources update loop")
for {
select {
case <-ctx.Done():
@@ -127,3 +133,33 @@ func (c *Config) updateResourcesLoop(ctx context.Context) {
}
}
}
func (c *Config) refreshSessionLoop(ctx context.Context) {
ticker := time.NewTicker(SessionRefreshInterval)
defer ticker.Stop()
log.Trace().Str("cluster", c.client.Cluster.Name).Msg("[proxmox] starting session refresh loop")
numRetries := 0
for {
select {
case <-ctx.Done():
log.Trace().Str("cluster", c.client.Cluster.Name).Msg("[proxmox] stopping session refresh loop")
return
case <-ticker.C:
reqCtx, reqCtxCancel := context.WithTimeout(ctx, SessionRefreshInterval)
err := c.client.RefreshSession(reqCtx)
reqCtxCancel()
if err != nil {
log.Error().Err(err).Str("cluster", c.client.Cluster.Name).Msg("[proxmox] failed to refresh session")
// exponential backoff
numRetries++
backoff := time.Duration(min(math.Pow(2, float64(numRetries)), 10)) * time.Second
ticker.Reset(backoff)
} else {
ticker.Reset(SessionRefreshInterval)
}
}
}
}

View File

@@ -39,15 +39,30 @@ func (n *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.Rea
// LXCJournalctl streams journalctl output for the given service.
//
// If service is not empty, it will be used to filter the output by service.
// On non systemd systems, it will tail /var/log/messages as fallback.
//
// If services are not empty, it will be used to filter the output by service.
// If limit is greater than 0, it will be used to limit the number of lines of output.
func (n *Node) LXCJournalctl(ctx context.Context, vmid int, service string, limit int) (io.ReadCloser, error) {
command := "journalctl -f"
if service != "" {
command = fmt.Sprintf("journalctl -u %q -f", service)
func (n *Node) LXCJournalctl(ctx context.Context, vmid int, services []string, limit int) (io.ReadCloser, error) {
command, err := formatJournalctl(services, limit)
if err != nil {
return nil, err
}
if limit > 0 {
command = fmt.Sprintf("%s -n %d", command, limit)
if len(services) == 0 {
// add /var/log/messages fallback for non systemd systems
// in tail command, try --retry first, if it fails, try the command again
command = fmt.Sprintf("sh -c '%s 2>/dev/null || tail -f -q --retry /var/log/messages 2>/dev/null || tail -f -q /var/log/messages'", command)
}
return n.LXCCommand(ctx, vmid, command)
}
// LXCTail streams tail output for the given file.
//
// If limit is greater than 0, it will be used to limit the number of lines of output.
func (n *Node) LXCTail(ctx context.Context, vmid int, files []string, limit int) (io.ReadCloser, error) {
command, err := formatTail(files, limit)
if err != nil {
return nil, err
}
return n.LXCCommand(ctx, vmid, command)
}

View File

@@ -2,18 +2,20 @@ package proxmox
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/bytedance/sonic"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/pool"
)
type NodeConfig struct {
Node string `json:"node" validate:"required"`
VMID int `json:"vmid" validate:"required"`
VMName string `json:"vmname,omitempty"`
Service string `json:"service,omitempty"`
Node string `json:"node"`
VMID *int `json:"vmid"` // unset: auto discover; explicit 0: node-level route; >0: lxc/qemu resource route
VMName string `json:"vmname,omitempty"`
Services []string `json:"services,omitempty" aliases:"service"`
Files []string `json:"files,omitempty" aliases:"file"`
} // @name ProxmoxNodeConfig
type Node struct {
@@ -24,6 +26,22 @@ type Node struct {
// statsScriptInitErrs *xsync.Map[int, error]
}
// Validate implements the serialization.CustomValidator interface.
func (n *NodeConfig) Validate() gperr.Error {
var errs gperr.Builder
for i, service := range n.Services {
if err := checkValidInput(service); err != nil {
errs.AddSubjectf(err, "services[%d]", i)
}
}
for i, file := range n.Files {
if err := checkValidInput(file); err != nil {
errs.AddSubjectf(err, "files[%d]", i)
}
}
return errs.Error()
}
var Nodes = pool.New[*Node]("proxmox_nodes")
func NewNode(client *Client, name, id string) *Node {
@@ -64,7 +82,7 @@ func (n *Node) String() string {
}
func (n *Node) MarshalJSON() ([]byte, error) {
return sonic.Marshal(map[string]any{
return json.Marshal(map[string]any{
"name": n.name,
"id": n.id,
})

View File

@@ -50,6 +50,7 @@ func (n *Node) NodeCommand(ctx context.Context, command string) (io.ReadCloser,
// Send command
cmd := []byte(command + "\n")
if err := handleSend(cmd); err != nil {
closeFn()
return nil, err
}
@@ -70,6 +71,7 @@ func (n *Node) NodeCommand(ctx context.Context, command string) (io.ReadCloser,
for {
select {
case <-ctx.Done():
_ = pw.CloseWithError(ctx.Err())
return
case msg := <-recv:
// skip the header message like
@@ -106,7 +108,6 @@ func (n *Node) NodeCommand(ctx context.Context, command string) (io.ReadCloser,
case err := <-errs:
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
_ = pw.Close()
return
}
_ = pw.CloseWithError(err)
@@ -119,13 +120,25 @@ func (n *Node) NodeCommand(ctx context.Context, command string) (io.ReadCloser,
return pr, nil
}
func (n *Node) NodeJournalctl(ctx context.Context, service string, limit int) (io.ReadCloser, error) {
command := "journalctl -f"
if service != "" {
command = fmt.Sprintf("journalctl -u %q -f", service)
}
if limit > 0 {
command = fmt.Sprintf("%s -n %d", command, limit)
// NodeJournalctl streams journalctl output for the given service.
//
// If services are not empty, it will be used to filter the output by services.
// If limit is greater than 0, it will be used to limit the number of lines of output.
func (n *Node) NodeJournalctl(ctx context.Context, services []string, limit int) (io.ReadCloser, error) {
command, err := formatJournalctl(services, limit)
if err != nil {
return nil, err
}
return n.NodeCommand(ctx, command)
}
// NodeTail streams tail output for the given file.
//
// If limit is greater than 0, it will be used to limit the number of lines of output.
func (n *Node) NodeTail(ctx context.Context, files []string, limit int) (io.ReadCloser, error) {
command, err := formatTail(files, limit)
if err != nil {
return nil, err
}
return n.NodeCommand(ctx, command)
}

View File

@@ -0,0 +1,56 @@
package proxmox
import (
"testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/serialization"
)
func TestValidateCommandArgs(t *testing.T) {
tests := []struct {
name string
yamlCfg string
wantErr bool
}{
{
name: "valid_services",
yamlCfg: `services: ["foo", "bar"]`,
wantErr: false,
},
{
name: "invalid_services",
yamlCfg: `services: ["foo", "bar & baz"]`,
wantErr: true,
},
{
name: "invalid_services_with_$(",
yamlCfg: `services: ["foo", "bar & $(echo 'hello')"]`,
wantErr: true,
},
{
name: "valid_files",
yamlCfg: `files: ["foo", "bar"]`,
wantErr: false,
},
{
name: "invalid_files",
yamlCfg: `files: ["foo", "bar & baz"]`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var cfg NodeConfig
err := serialization.UnmarshalValidate([]byte(tt.yamlCfg), &cfg, yaml.Unmarshal)
if tt.wantErr {
require.Error(t, err)
require.ErrorContains(t, err, "input contains invalid characters")
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -3,8 +3,8 @@ package provider
import (
"testing"
"github.com/docker/docker/api/types/container"
"github.com/goccy/go-yaml"
"github.com/moby/moby/api/types/container"
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/types"
expect "github.com/yusing/goutils/testing"
@@ -26,7 +26,7 @@ func TestParseDockerLabels(t *testing.T) {
Names: []string{"container"},
Labels: labels,
State: "running",
Ports: []container.PortSummary{
Ports: []container.Port{
{Type: "tcp", PrivatePort: 1234, PublicPort: 1234},
},
}, types.DockerProviderConfig{URL: "unix:///var/run/docker.sock"}),

View File

@@ -1,13 +1,12 @@
package provider
import (
"net/netip"
"testing"
"time"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
D "github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/route"
routeTypes "github.com/yusing/godoxy/internal/route/types"
@@ -276,7 +275,7 @@ func TestPrivateIPLocalhost(t *testing.T) {
NetworkSettings: &container.NetworkSettingsSummary{
Networks: map[string]*network.EndpointSettings{
"network": {
IPAddress: netip.MustParseAddr(testDockerIP),
IPAddress: testDockerIP,
},
},
},
@@ -294,7 +293,7 @@ func TestPrivateIPRemote(t *testing.T) {
NetworkSettings: &container.NetworkSettingsSummary{
Networks: map[string]*network.EndpointSettings{
"network": {
IPAddress: netip.MustParseAddr(testDockerIP),
IPAddress: testDockerIP,
},
},
},
@@ -316,11 +315,11 @@ func TestStreamDefaultValues(t *testing.T) {
NetworkSettings: &container.NetworkSettingsSummary{
Networks: map[string]*network.EndpointSettings{
"network": {
IPAddress: netip.MustParseAddr(privIP),
IPAddress: privIP,
},
},
},
Ports: []container.PortSummary{
Ports: []container.Port{
{Type: "udp", PrivatePort: privPort, PublicPort: pubPort},
},
}
@@ -373,7 +372,7 @@ func TestImplicitExcludeDatabase(t *testing.T) {
t.Run("exposed port detection", func(t *testing.T) {
r, ok := makeRoutes(&container.Summary{
Names: dummyNames,
Ports: []container.PortSummary{
Ports: []container.Port{
{Type: "tcp", PrivatePort: 5432, PublicPort: 5432},
},
})["a"]

View File

@@ -5,6 +5,7 @@ import (
"path"
"strings"
"github.com/goccy/go-yaml"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
@@ -43,7 +44,7 @@ func removeXPrefix(m map[string]any) gperr.Error {
}
func validate(data []byte) (routes route.Routes, err gperr.Error) {
err = serialization.UnmarshalValidateYAMLIntercept(data, &routes, removeXPrefix)
err = serialization.UnmarshalValidate(data, &routes, yaml.Unmarshal, removeXPrefix)
return routes, err
}

View File

@@ -69,7 +69,7 @@ type (
Idlewatcher *types.IdlewatcherConfig `json:"idlewatcher,omitempty" extensions:"x-nullable"`
Metadata `deserialize:"-"`
}
} // @name Route
Metadata struct {
/* Docker only */
@@ -185,87 +185,68 @@ func (r *Route) validate() gperr.Error {
if r.Proxmox != nil && r.Idlewatcher != nil {
r.Idlewatcher.Proxmox = &types.ProxmoxConfig{
Node: r.Proxmox.Node,
VMID: r.Proxmox.VMID,
}
if r.Proxmox.VMID != nil {
r.Idlewatcher.Proxmox.VMID = *r.Proxmox.VMID
}
}
if r.Proxmox == nil && r.Idlewatcher != nil && r.Idlewatcher.Proxmox != nil {
r.Proxmox = &proxmox.NodeConfig{
Node: r.Idlewatcher.Proxmox.Node,
VMID: r.Idlewatcher.Proxmox.VMID,
VMID: &r.Idlewatcher.Proxmox.VMID,
}
}
if (r.Proxmox == nil || r.Proxmox.Node == "" || r.Proxmox.VMID == nil) && r.Container == nil {
wasNotNil := r.Proxmox != nil
proxmoxProviders := config.WorkingState.Load().Value().Providers.Proxmox
if len(proxmoxProviders) > 0 {
// it's fine if ip is nil
hostname := r.Host
ip := net.ParseIP(hostname)
for _, p := range proxmoxProviders {
// First check if hostname, IP, or alias matches a node (node-level route)
if nodeName := p.Client().ReverseLookupNode(hostname, ip, r.Alias); nodeName != "" {
zero := 0
if r.Proxmox == nil {
r.Proxmox = &proxmox.NodeConfig{}
}
r.Proxmox.Node = nodeName
r.Proxmox.VMID = &zero
r.Proxmox.VMName = ""
log.Info().
Str("node", nodeName).
Msgf("found proxmox node for route %q", r.Alias)
break
}
// Then check if hostname, IP, or alias matches a VM resource
resource, _ := p.Client().ReverseLookupResource(ip, hostname, r.Alias)
if resource != nil {
vmid := int(resource.VMID)
if r.Proxmox == nil {
r.Proxmox = &proxmox.NodeConfig{}
}
r.Proxmox.Node = resource.Node
r.Proxmox.VMID = &vmid
r.Proxmox.VMName = resource.Name
log.Info().
Str("node", resource.Node).
Int("vmid", int(resource.VMID)).
Str("vmname", resource.Name).
Msgf("found proxmox resource for route %q", r.Alias)
break
}
}
}
if wasNotNil && (r.Proxmox.Node == "" || r.Proxmox.VMID == nil) {
log.Warn().Msgf("no proxmox node / resource found for route %q", r.Alias)
}
}
if r.Proxmox != nil {
nodeName := r.Proxmox.Node
vmid := r.Proxmox.VMID
if nodeName == "" {
return gperr.Errorf("node (proxmox node name) is required")
}
node, ok := proxmox.Nodes.Get(nodeName)
if !ok {
return gperr.Errorf("proxmox node %s not found in pool", nodeName)
}
// Node-level route (VMID = 0) - no container control needed
if vmid > 0 {
res, err := node.Client().GetResource("lxc", vmid)
if err != nil {
return gperr.Wrap(err) // ErrResourceNotFound
}
r.Proxmox.VMName = res.Name
if r.Host == DefaultHost {
containerName := r.Idlewatcher.ContainerName()
// get ip addresses of the vmid
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips := res.IPs
if len(ips) == 0 {
return gperr.Multiline().
Addf("no ip addresses found for %s", containerName).
Adds("make sure you have set static ip address for container instead of dhcp").
Subject(containerName)
}
l := log.With().Str("container", containerName).Logger()
l.Info().Msg("checking if container is running")
running, err := node.LXCIsRunning(ctx, vmid)
if err != nil {
return gperr.New("failed to check container state").With(err)
}
if !running {
l.Info().Msg("starting container")
if err := node.LXCAction(ctx, vmid, proxmox.LXCStart); err != nil {
return gperr.New("failed to start container").With(err)
}
}
l.Info().Msgf("finding reachable ip addresses")
errs := gperr.NewBuilder("failed to find reachable ip addresses")
for _, ip := range ips {
if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil {
errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy))
} else {
r.Host = ip.String()
l.Info().Msgf("using ip %s", r.Host)
break
}
}
if r.Host == DefaultHost {
return gperr.Multiline().
Addf("no reachable ip addresses found, tried %d IPs", len(ips)).
With(errs.Error()).
Subject(containerName)
}
}
}
r.validateProxmox()
}
if r.Container != nil && r.Container.IdlewatcherConfig != nil {
@@ -336,45 +317,6 @@ func (r *Route) validate() gperr.Error {
}
}
if r.Proxmox == nil && r.Container == nil {
proxmoxProviders := config.WorkingState.Load().Value().Providers.Proxmox
if len(proxmoxProviders) > 0 {
// it's fine if ip is nil
hostname := r.ProxyURL.Hostname()
ip := net.ParseIP(hostname)
for _, p := range config.WorkingState.Load().Value().Providers.Proxmox {
// First check if hostname, IP, or alias matches a node (node-level route)
if nodeName := p.Client().ReverseLookupNode(hostname, ip, r.Alias); nodeName != "" {
r.Proxmox = &proxmox.NodeConfig{
Node: nodeName,
VMID: 0, // node-level route, no specific VM
VMName: "",
}
log.Info().
Str("node", nodeName).
Msgf("found proxmox node for route %q", r.Alias)
break
}
// Then check if hostname, IP, or alias matches a VM resource
resource, _ := p.Client().ReverseLookupResource(ip, hostname, r.Alias)
if resource != nil {
r.Proxmox = &proxmox.NodeConfig{
Node: resource.Node,
VMID: int(resource.VMID),
VMName: resource.Name,
}
log.Info().
Str("node", resource.Node).
Int("vmid", int(resource.VMID)).
Str("vmname", resource.Name).
Msgf("found proxmox resource for route %q", r.Alias)
break
}
}
}
}
if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) {
errs.Adds("cannot disable healthcheck when loadbalancer or idle watcher is enabled")
}
@@ -454,6 +396,90 @@ func (r *Route) validateRules() error {
return nil
}
func (r *Route) validateProxmox() {
l := log.With().Str("route", r.Alias).Logger()
nodeName := r.Proxmox.Node
vmid := r.Proxmox.VMID
if nodeName == "" || vmid == nil {
l.Error().Msg("node (proxmox node name) is required")
return
}
node, ok := proxmox.Nodes.Get(nodeName)
if !ok {
l.Error().Msgf("proxmox node %s not found in pool", nodeName)
return
}
// Node-level route (VMID = 0)
if *vmid == 0 {
r.Scheme = route.SchemeHTTPS
if r.Host == DefaultHost {
r.Host = node.Client().BaseURL.Hostname()
}
port, _ := strconv.Atoi(node.Client().BaseURL.Port())
if port == 0 {
port = 8006
}
r.Port.Proxy = port
} else {
res, err := node.Client().GetResource("lxc", *vmid)
if err != nil { // ErrResourceNotFound
l.Err(err).Msgf("failed to get resource %d", *vmid)
return
}
r.Proxmox.VMName = res.Name
if r.Host == DefaultHost {
containerName := res.Name
// get ip addresses of the vmid
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips := res.IPs
if len(ips) == 0 {
l.Warn().Msgf("no ip addresses found for %s, make sure you have set static ip address for container instead of dhcp", containerName)
return
}
l = l.With().Str("container", containerName).Logger()
l.Info().Msgf("checking if container is running")
running, err := node.LXCIsRunning(ctx, *vmid)
if err != nil {
l.Err(err).Msgf("failed to check container state")
return
}
if !running {
l.Info().Msgf("starting container")
if err := node.LXCAction(ctx, *vmid, proxmox.LXCStart); err != nil {
l.Err(err).Msgf("failed to start container")
return
}
}
l.Info().Msgf("finding reachable ip addresses")
errs := gperr.NewBuilder("failed to find reachable ip addresses")
for _, ip := range ips {
if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil {
errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy))
} else {
r.Host = ip.String()
l.Info().Msgf("using ip %s", r.Host)
break
}
}
if r.Host == DefaultHost {
l.Warn().Err(errs.Error()).Msgf("no reachable ip addresses found, tried %d IPs", len(ips))
}
}
}
}
func (r *Route) Impl() types.Route {
return r.impl
}
@@ -556,11 +582,11 @@ func (r *Route) References() []string {
}
if r.Proxmox != nil {
if r.Proxmox.Service != "" && r.Proxmox.Service != aliasRef {
if len(r.Proxmox.Services) > 0 && r.Proxmox.Services[0] != aliasRef {
if r.Proxmox.VMName != aliasRef {
return []string{r.Proxmox.VMName, aliasRef, r.Proxmox.Service}
return []string{r.Proxmox.VMName, aliasRef, r.Proxmox.Services[0]}
}
return []string{r.Proxmox.Service, aliasRef}
return []string{r.Proxmox.Services[0], aliasRef}
} else {
if r.Proxmox.VMName != aliasRef {
return []string{r.Proxmox.VMName, aliasRef}

View File

@@ -79,6 +79,10 @@ var commands = map[string]struct {
},
build: func(args any) CommandHandler {
return NonTerminatingCommand(func(w http.ResponseWriter, r *http.Request) error {
if authHandler == nil {
http.Error(w, "Auth handler not initialized", http.StatusInternalServerError)
return errTerminated
}
if !authHandler(w, r) {
return errTerminated
}

View File

@@ -244,7 +244,7 @@ func (rules Rules) BuildHandler(up http.HandlerFunc) http.HandlerFunc {
}
func appendRuleError(rm *httputils.ResponseModifier, rule *Rule, err error) {
rm.AppendError("rule: %s, error: %w", rule.Name, err)
// rm.AppendError("rule: %s, error: %w", rule.Name, err)
}
func isTerminatingHandler(handler CommandHandler) bool {

View File

@@ -1,9 +1,9 @@
package route
import (
"encoding/json"
"strconv"
"github.com/bytedance/sonic"
gperr "github.com/yusing/goutils/errs"
)
@@ -57,7 +57,7 @@ func (s Scheme) MarshalJSON() ([]byte, error) {
func (s *Scheme) UnmarshalJSON(data []byte) error {
var v string
if err := sonic.Unmarshal(data, &v); err != nil {
if err := json.Unmarshal(data, &v); err != nil {
return err
}
return s.Parse(v)

View File

@@ -11,7 +11,7 @@ This package provides robust YAML/JSON serialization with:
- Case-insensitive field matching using FNV-1a hashing
- Environment variable substitution (`${VAR}` syntax)
- Field-level validation with go-playground/validator tags
- Custom type conversion with alias support
- Custom type conversion with pluggable format handlers
### Primary Consumers
@@ -55,21 +55,27 @@ type CustomValidator interface {
### Deserialization Functions
```go
// YAML with full validation
func UnmarshalValidateYAML[T any](data []byte, target *T) gperr.Error
// Generic unmarshal with pluggable format handler
func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) gperr.Error
// YAML with interceptor for preprocessing
func UnmarshalValidateYAMLIntercept[T any](
data []byte,
target *T,
intercept func(m map[string]any) gperr.Error,
) gperr.Error
// Read from io.Reader with format decoder
func UnmarshalValidateReader[T any](reader io.Reader, target *T, newDecoder newDecoderFunc, interceptFns ...interceptFunc) gperr.Error
// Direct map deserialization
func MapUnmarshalValidate(src SerializedObject, dst any) gperr.Error
// To xsync.Map
func UnmarshalValidateYAMLXSync[V any](data []byte) (*xsync.Map[string, V], gperr.Error)
// To xsync.Map with pluggable format handler
func UnmarshalValidateXSync[V any](data []byte, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) (*xsync.Map[string, V], gperr.Error)
```
### File I/O Functions
```go
// Write marshaled data to file
func SaveFile[T any](path string, src *T, perm os.FileMode, marshaler marshalFunc) error
// Read and unmarshal file if it exists
func LoadFileIfExist[T any](path string, dst *T, unmarshaler unmarshalFunc) error
```
### Conversion Functions
@@ -115,19 +121,19 @@ func ToSerializedObject[VT any](m map[string]VT) SerializedObject
```mermaid
sequenceDiagram
participant C as Caller
participant U as UnmarshalValidateYAML
participant U as UnmarshalValidate
participant E as Env Substitution
participant Y as YAML Parser
participant F as Format Parser
participant M as MapUnmarshalValidate
participant T as Type Info Cache
participant CV as Convert
participant V as Validator
C->>U: YAML bytes + target struct
C->>U: Data bytes + target struct + format handler
U->>E: Substitute ${ENV} vars
E-->>U: Substituted bytes
U->>Y: Parse YAML
Y-->>U: map[string]any
U->>F: Parse with format handler (YAML/JSON)
F-->>U: map[string]any
U->>M: Map + target
M->>T: Get type info
loop For each field in map
@@ -147,9 +153,9 @@ sequenceDiagram
```mermaid
flowchart TB
subgraph Input Processing
YAML[YAML Bytes] --> EnvSub[Env Substitution]
EnvSub --> YAMLParse[YAML Parse]
YAMLParse --> Map[map<string,any>]
Bytes[Data Bytes] --> EnvSub[Env Substitution]
EnvSub --> FormatParse[Format Parse]
FormatParse --> Map[map<string,any>]
end
subgraph Type Inspection
@@ -221,6 +227,7 @@ autocert:
### Internal Dependencies
- `github.com/yusing/goutils/errs` - Error handling
- `github.com/yusing/gointernals` - Reflection utilities
## Observability
@@ -251,11 +258,11 @@ ErrUnsupportedConversion.Subjectf("string to int")
| Validation failure | Structured error | Fix field value |
| Type mismatch | Error | Check field type |
| Missing env var | Error | Set environment variable |
| Invalid YAML | Error | Fix YAML syntax |
| Invalid format | Error | Fix YAML/JSON syntax |
## Usage Examples
### Basic Struct Deserialization
### YAML Deserialization
```go
type ServerConfig struct {
@@ -273,7 +280,16 @@ tls_enabled: true
`)
var config ServerConfig
if err := serialization.UnmarshalValidateYAML(yamlData, &config); err != nil {
if err := serialization.UnmarshalValidate(yamlData, &config, yaml.Unmarshal); err != nil {
panic(err)
}
```
### JSON Deserialization
```go
var config ServerConfig
if err := serialization.UnmarshalValidate(jsonData, &config, json.Unmarshal); err != nil {
panic(err)
}
```
@@ -293,7 +309,7 @@ func (c *Config) Validate() gperr.Error {
}
```
### Custom Type with Parse Method
### Custom Type with Parser Interface
```go
type Duration struct {
@@ -307,6 +323,31 @@ func (d *Duration) Parse(v string) error {
}
```
### Reading from File
```go
var config ServerConfig
if err := serialization.LoadFileIfExist("config.yml", &config, yaml.Unmarshal); err != nil {
panic(err)
}
// Save back to file
if err := serialization.SaveFile("config.yml", &config, 0644, yaml.Marshal); err != nil {
panic(err)
}
```
### Reading from io.Reader
```go
var config ServerConfig
file, _ := os.Open("config.yml")
defer file.Close()
if err := serialization.UnmarshalValidateReader(file, &config, yaml.NewDecoder); err != nil {
panic(err)
}
```
## Testing Notes
- `serialization_test.go` - Core functionality tests
@@ -319,3 +360,4 @@ func (d *Duration) Parse(v string) error {
- String conversions
- Environment substitution
- Custom validators
- Multiple format handlers (YAML/JSON)

View File

@@ -0,0 +1,37 @@
package serialization
import (
"encoding/json"
"net/http"
"github.com/goccy/go-yaml"
)
type (
GinJSONBinding struct{}
GinYAMLBinding struct{}
)
func (b GinJSONBinding) Name() string {
return "json"
}
func (b GinJSONBinding) Bind(req *http.Request, obj any) error {
m := make(map[string]any)
if err := json.NewDecoder(NewSubstituteEnvReader(req.Body)).Decode(&m); err != nil {
return err
}
return MapUnmarshalValidate(m, obj)
}
func (b GinYAMLBinding) Name() string {
return "yaml"
}
func (b GinYAMLBinding) Bind(req *http.Request, obj any) error {
m := make(map[string]any)
if err := yaml.NewDecoder(NewSubstituteEnvReader(req.Body)).Decode(&m); err != nil {
return err
}
return MapUnmarshalValidate(m, obj)
}

View File

@@ -0,0 +1,50 @@
package serialization_test
import (
"bytes"
"net/http/httptest"
"testing"
"github.com/yusing/godoxy/internal/serialization"
gperr "github.com/yusing/goutils/errs"
)
type TestStruct struct {
Value string `json:"value"`
Value2 int `json:"value2"`
}
func (t *TestStruct) Validate() gperr.Error {
if t.Value == "" {
return gperr.New("value is required")
}
if t.Value2 != 0 && (t.Value2 < 5 || t.Value2 > 10) {
return gperr.New("value2 must be between 5 and 10")
}
return nil
}
func TestGinBinding(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"valid1", `{"value": "test", "value2": 7}`, false},
{"valid2", `{"value": "test"}`, false},
{"invalid1", `{"value2": 7}`, true},
{"invalid2", `{"value": "test", "value2": 3}`, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var dst TestStruct
body := bytes.NewBufferString(tt.input)
req := httptest.NewRequest("POST", "/", body)
err := serialization.GinJSONBinding{}.Bind(req, &dst)
if (err != nil) != tt.wantErr {
t.Errorf("%s: Bind() error = %v, wantErr %v", tt.name, err, tt.wantErr)
}
})
}
}

View File

@@ -0,0 +1,146 @@
package serialization
import (
"bytes"
"io"
)
type SubstituteEnvReader struct {
reader io.Reader
buf []byte // buffered data with substitutions applied
err error // sticky error
}
func NewSubstituteEnvReader(reader io.Reader) *SubstituteEnvReader {
return &SubstituteEnvReader{reader: reader}
}
const peekSize = 4096
const maxVarNameLength = 256
func (r *SubstituteEnvReader) Read(p []byte) (n int, err error) {
// Return buffered data first
if len(r.buf) > 0 {
n = copy(p, r.buf)
r.buf = r.buf[n:]
return n, nil
}
// Return sticky error if we have one
if r.err != nil {
return 0, r.err
}
var buf [2 * peekSize]byte
// Read a chunk from the underlying reader
chunk, more := buf[:peekSize], buf[peekSize:]
nRead, readErr := r.reader.Read(chunk)
if nRead == 0 {
if readErr != nil {
return 0, readErr
}
return 0, io.EOF
}
chunk = chunk[:nRead]
// Check if there's a potential incomplete pattern at the end
// Pattern: ${VAR_NAME}
// We need to check if chunk ends with a partial pattern like "$", "${", "${VAR", etc.
incompleteStart := findIncompletePatternStart(chunk)
if incompleteStart >= 0 && readErr == nil {
// There might be an incomplete pattern, read more to complete it
incomplete := chunk[incompleteStart:]
chunk = chunk[:incompleteStart]
// Keep reading until we complete the pattern or hit EOF/error
for {
// Limit how much we buffer to prevent memory exhaustion
if len(incomplete) > maxVarNameLength+3 { // ${} + var name
// Pattern too long to be valid, give up and process as-is
chunk = append(chunk, incomplete...)
break
}
nMore, moreErr := r.reader.Read(more)
if nMore > 0 {
incomplete = append(incomplete, more[:nMore]...)
// Check if pattern is now complete
if idx := bytes.IndexByte(incomplete, '}'); idx >= 0 {
// Pattern complete, append the rest back to chunk
chunk = append(chunk, incomplete...)
break
}
}
if moreErr != nil {
// No more data, append whatever we have
chunk = append(chunk, incomplete...)
readErr = moreErr
break
}
}
}
substituted, subErr := substituteEnv(chunk)
if subErr != nil {
r.err = subErr
return 0, subErr
}
n = copy(p, substituted)
if n < len(substituted) {
// Buffer the rest
r.buf = substituted[n:]
}
// Store sticky error for next read
if readErr != nil && readErr != io.EOF {
r.err = readErr
} else {
if readErr == io.EOF && n > 0 {
return n, nil
}
if readErr == io.EOF {
return n, io.EOF
}
}
return n, nil
}
// findIncompletePatternStart returns the index where an incomplete ${...} pattern starts,
// or -1 if there's no incomplete pattern at the end.
func findIncompletePatternStart(data []byte) int {
// Look for '$' near the end that might be start of ${VAR}
// Maximum var name we reasonably expect + "${}" = ~256 chars
searchStart := max(0, len(data)-maxVarNameLength)
for i := len(data) - 1; i >= searchStart; i-- {
if data[i] == '$' {
// Check if this is a complete pattern or incomplete
if i+1 >= len(data) {
// Just "$" at end
return i
}
if data[i+1] == '{' {
// Check if there's anything after "${"
if i+2 >= len(data) {
// Just "${" at end
return i
}
// Check if pattern is complete by looking for '}'
for j := i + 2; j < len(data); j++ {
if data[j] == '}' {
// This pattern is complete, continue searching for another
break
}
if j == len(data)-1 {
// Reached end without finding '}', incomplete pattern
return i
}
}
}
}
}
return -1
}

View File

@@ -0,0 +1,286 @@
package serialization
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
// setupEnv sets up environment variables for benchmarks
func setupEnv(b *testing.B) {
b.Helper()
os.Setenv("BENCH_VAR", "benchmark_value")
os.Setenv("BENCH_VAR_2", "second_value")
os.Setenv("BENCH_VAR_3", "third_value")
}
// cleanupEnv cleans up environment variables after benchmarks
func cleanupEnv(b *testing.B) {
b.Helper()
os.Unsetenv("BENCH_VAR")
os.Unsetenv("BENCH_VAR_2")
os.Unsetenv("BENCH_VAR_3")
}
// BenchmarkSubstituteEnvReader_NoSubstitution benchmarks reading without any env substitutions
func BenchmarkSubstituteEnvReader_NoSubstitution(b *testing.B) {
r := strings.NewReader(`key: value
name: test
data: some content here
`)
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_SingleSubstitution benchmarks reading with a single env substitution
func BenchmarkSubstituteEnvReader_SingleSubstitution(b *testing.B) {
setupEnv(b)
defer cleanupEnv(b)
r := strings.NewReader(`key: ${BENCH_VAR}
`)
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_MultipleSubstitutions benchmarks reading with multiple env substitutions
func BenchmarkSubstituteEnvReader_MultipleSubstitutions(b *testing.B) {
setupEnv(b)
defer cleanupEnv(b)
r := strings.NewReader(`key1: ${BENCH_VAR}
key2: ${BENCH_VAR_2}
key3: ${BENCH_VAR_3}
`)
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_LargeInput_NoSubstitution benchmarks large input without substitutions
func BenchmarkSubstituteEnvReader_LargeInput_NoSubstitution(b *testing.B) {
r := strings.NewReader(strings.Repeat("x", 100000))
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_LargeInput_WithSubstitutions benchmarks large input with scattered substitutions
func BenchmarkSubstituteEnvReader_LargeInput_WithSubstitutions(b *testing.B) {
setupEnv(b)
defer cleanupEnv(b)
var builder bytes.Buffer
for range 100 {
builder.WriteString(strings.Repeat("x", 1000))
builder.WriteString("${BENCH_VAR}")
}
r := bytes.NewReader(builder.Bytes())
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_SmallBuffer benchmarks reading with a small buffer size
func BenchmarkSubstituteEnvReader_SmallBuffer(b *testing.B) {
setupEnv(b)
defer cleanupEnv(b)
r := strings.NewReader(`key: ${BENCH_VAR} and some more content here`)
buf := make([]byte, 16)
for b.Loop() {
reader := NewSubstituteEnvReader(r)
for {
_, err := reader.Read(buf)
if err == io.EOF {
break
}
if err != nil {
b.Fatal(err)
}
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_YAMLConfig benchmarks a realistic YAML config scenario
func BenchmarkSubstituteEnvReader_YAMLConfig(b *testing.B) {
setupEnv(b)
defer cleanupEnv(b)
r := strings.NewReader(`database:
host: ${BENCH_VAR}
port: ${BENCH_VAR_2}
username: ${BENCH_VAR_3}
password: ${BENCH_VAR}
cache:
enabled: true
ttl: ${BENCH_VAR_2}
server:
host: ${BENCH_VAR}
port: 8080
`)
b.ResetTimer()
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_BoundaryPattern benchmarks patterns at buffer boundaries (4096 bytes)
func BenchmarkSubstituteEnvReader_BoundaryPattern(b *testing.B) {
setupEnv(b)
defer cleanupEnv(b)
// Pattern exactly at 4090 bytes, with ${VAR} crossing the 4096 boundary
prefix := strings.Repeat("x", 4090)
r := strings.NewReader(prefix + "${BENCH_VAR}")
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_MultipleBoundaries benchmarks multiple patterns crossing boundaries
func BenchmarkSubstituteEnvReader_MultipleBoundaries(b *testing.B) {
setupEnv(b)
defer cleanupEnv(b)
var builder bytes.Buffer
for range 10 {
builder.WriteString(strings.Repeat("x", 4000))
builder.WriteString("${BENCH_VAR}")
}
r := bytes.NewReader(builder.Bytes())
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_SpecialChars benchmarks substitution with special characters
func BenchmarkSubstituteEnvReader_SpecialChars(b *testing.B) {
os.Setenv("SPECIAL_BENCH_VAR", `value with "quotes" and \backslash\`)
defer os.Unsetenv("SPECIAL_BENCH_VAR")
r := strings.NewReader(`key: ${SPECIAL_BENCH_VAR}
`)
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_EmptyValue benchmarks substitution with empty value
func BenchmarkSubstituteEnvReader_EmptyValue(b *testing.B) {
os.Setenv("EMPTY_BENCH_VAR", "")
defer os.Unsetenv("EMPTY_BENCH_VAR")
r := strings.NewReader(`key: ${EMPTY_BENCH_VAR}
`)
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkSubstituteEnvReader_DollarWithoutBrace benchmarks $ without following {
func BenchmarkSubstituteEnvReader_DollarWithoutBrace(b *testing.B) {
os.Setenv("BENCH_VAR", "benchmark_value")
defer os.Unsetenv("BENCH_VAR")
r := strings.NewReader(`price: $100 and $200 for ${BENCH_VAR}`)
for b.Loop() {
reader := NewSubstituteEnvReader(r)
_, err := io.ReadAll(reader)
if err != nil {
b.Fatal(err)
}
r.Seek(0, io.SeekStart)
}
}
// BenchmarkFindIncompletePatternStart benchmarks the findIncompletePatternStart function
func BenchmarkFindIncompletePatternStart(b *testing.B) {
testCases := []struct {
name string
input string
}{
{"no pattern", strings.Repeat("hello world ", 100)},
{"complete pattern", strings.Repeat("hello ${VAR} world ", 50)},
{"dollar at end", strings.Repeat("hello ", 100) + "$"},
{"incomplete at end", strings.Repeat("hello ", 100) + "${VAR"},
{"large input no pattern", strings.Repeat("x", 5000)},
{"large input with pattern", strings.Repeat("x", 4000) + "${VAR}"},
}
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
data := []byte(tc.input)
for b.Loop() {
findIncompletePatternStart(data)
}
})
}
}

View File

@@ -0,0 +1,217 @@
package serialization
import (
"bytes"
"io"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestSubstituteEnvReader_Basic(t *testing.T) {
os.Setenv("TEST_VAR", "hello")
defer os.Unsetenv("TEST_VAR")
input := []byte(`key: ${TEST_VAR}`)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, `key: "hello"`, string(output))
}
func TestSubstituteEnvReader_Multiple(t *testing.T) {
os.Setenv("VAR1", "first")
os.Setenv("VAR2", "second")
defer os.Unsetenv("VAR1")
defer os.Unsetenv("VAR2")
input := []byte(`a: ${VAR1}, b: ${VAR2}`)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, `a: "first", b: "second"`, string(output))
}
func TestSubstituteEnvReader_NoSubstitution(t *testing.T) {
input := []byte(`key: value`)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, `key: value`, string(output))
}
func TestSubstituteEnvReader_UnsetEnvError(t *testing.T) {
os.Unsetenv("UNSET_VAR_FOR_TEST")
input := []byte(`key: ${UNSET_VAR_FOR_TEST}`)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
_, err := io.ReadAll(reader)
require.Error(t, err)
require.Contains(t, err.Error(), "UNSET_VAR_FOR_TEST is not set")
}
func TestSubstituteEnvReader_SmallBuffer(t *testing.T) {
os.Setenv("SMALL_BUF_VAR", "value")
defer os.Unsetenv("SMALL_BUF_VAR")
input := []byte(`key: ${SMALL_BUF_VAR}`)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
var result []byte
buf := make([]byte, 3)
for {
n, err := reader.Read(buf)
if n > 0 {
result = append(result, buf[:n]...)
}
if err == io.EOF {
break
}
require.NoError(t, err)
}
require.Equal(t, `key: "value"`, string(result))
}
func TestSubstituteEnvReader_SpecialChars(t *testing.T) {
os.Setenv("SPECIAL_VAR", `hello "world" \n`)
defer os.Unsetenv("SPECIAL_VAR")
input := []byte(`key: ${SPECIAL_VAR}`)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, `key: "hello \"world\" \\n"`, string(output))
}
func TestSubstituteEnvReader_EmptyValue(t *testing.T) {
os.Setenv("EMPTY_VAR", "")
defer os.Unsetenv("EMPTY_VAR")
input := []byte(`key: ${EMPTY_VAR}`)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, `key: ""`, string(output))
}
func TestSubstituteEnvReader_LargeInput(t *testing.T) {
os.Setenv("LARGE_VAR", "replaced")
defer os.Unsetenv("LARGE_VAR")
prefix := strings.Repeat("x", 5000)
suffix := strings.Repeat("y", 5000)
input := []byte(prefix + "${LARGE_VAR}" + suffix)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
expected := prefix + `"replaced"` + suffix
require.Equal(t, expected, string(output))
}
func TestSubstituteEnvReader_PatternAtBoundary(t *testing.T) {
os.Setenv("BOUNDARY_VAR", "boundary_value")
defer os.Unsetenv("BOUNDARY_VAR")
prefix := strings.Repeat("a", 4090)
input := []byte(prefix + "${BOUNDARY_VAR}")
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
expected := prefix + `"boundary_value"`
require.Equal(t, expected, string(output))
}
func TestSubstituteEnvReader_MultiplePatternsBoundary(t *testing.T) {
os.Setenv("VAR_A", "aaa")
os.Setenv("VAR_B", "bbb")
defer os.Unsetenv("VAR_A")
defer os.Unsetenv("VAR_B")
prefix := strings.Repeat("x", 4090)
input := []byte(prefix + "${VAR_A} middle ${VAR_B}")
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
expected := prefix + `"aaa" middle "bbb"`
require.Equal(t, expected, string(output))
}
func TestSubstituteEnvReader_YAMLConfig(t *testing.T) {
os.Setenv("DB_HOST", "localhost")
os.Setenv("DB_PORT", "5432")
os.Setenv("DB_PASSWORD", "secret123")
defer os.Unsetenv("DB_HOST")
defer os.Unsetenv("DB_PORT")
defer os.Unsetenv("DB_PASSWORD")
input := []byte(`database:
host: ${DB_HOST}
port: ${DB_PORT}
password: ${DB_PASSWORD}
`)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
expected := `database:
host: "localhost"
port: "5432"
password: "secret123"
`
require.Equal(t, expected, string(output))
}
func TestSubstituteEnvReader_DollarWithoutBrace(t *testing.T) {
input := []byte(`key: $NOT_A_PATTERN`)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, `key: $NOT_A_PATTERN`, string(output))
}
func TestSubstituteEnvReader_EmptyInput(t *testing.T) {
input := []byte(``)
reader := NewSubstituteEnvReader(bytes.NewReader(input))
output, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, ``, string(output))
}
func TestFindIncompletePatternStart(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{"no pattern", "hello world", -1},
{"complete pattern", "hello ${VAR} world", -1},
{"dollar at end", "hello $", 6},
{"dollar brace at end", "hello ${", 6},
{"incomplete var at end", "hello ${VAR", 6},
{"complete then incomplete", "hello ${VAR} ${INCOMPLETE", 13},
{"multiple complete", "${A} ${B} ${C}", -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := findIncompletePatternStart([]byte(tt.input))
require.Equal(t, tt.expected, result)
})
}
}

View File

@@ -2,6 +2,7 @@ package serialization
import (
"errors"
"io"
"os"
"reflect"
"regexp"
@@ -85,6 +86,10 @@ func initPtr(dst reflect.Value) {
}
}
// Validate performs struct validation using go-playground/validator tags.
//
// It collects all validation errors and returns them as a single error.
// Field names in errors are prefixed with their namespace (e.g., "User.Email").
func ValidateWithFieldTags(s any) gperr.Error {
var errs gperr.Builder
err := validate.Struct(s)
@@ -257,10 +262,11 @@ func initTypeKeyFieldIndexesMap(t reflect.Type) typeInfo {
}
}
// MapUnmarshalValidate takes a SerializedObject and a target value, and assigns the values in the SerializedObject to the target value.
// MapUnmarshalValidate ignores case differences between the field names in the SerializedObject and the target.
// MapUnmarshalValidate takes a SerializedObject and a target value,
// and assigns the values in the SerializedObject to the target value.
//
// It ignores case differences between the field names in the SerializedObject and the target.
//
// The target value must be a struct or a map[string]any.
// If the target value is a struct , and implements the MapUnmarshaller interface,
// the UnmarshalMap method will be called.
//
@@ -309,7 +315,7 @@ func mapUnmarshalValidate(src SerializedObject, dstV reflect.Value, checkValidat
info := getTypeInfo(dstT)
for k, v := range src {
if field, ok := info.getField(dstV, k); ok {
err := Convert(reflect.ValueOf(v), field, !info.hasValidateTag)
err := Convert(reflect.ValueOf(v), field, checkValidateTag)
if err != nil {
errs.Add(err.Subject(k))
}
@@ -455,6 +461,13 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
}
// ConvertSlice converts a source slice to a destination slice.
//
// - Elements are converted one by one using the Convert function.
// - Validation is performed on each element if checkValidateTag is true.
// - The destination slice is initialized with the source length.
// - On error, the destination slice is truncated to the number of
// successfully converted elements.
func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error {
if dst.Kind() == reflect.Pointer {
if dst.IsNil() && !dst.CanSet() {
@@ -507,6 +520,12 @@ func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) g
return nil
}
// ConvertString converts a string value to the destination reflect.Value.
// - It handles various types including numeric types, booleans, time.Duration,
// slices (comma-separated or YAML), maps, and structs (YAML).
// - If the destination implements the Parser interface, it is used for conversion.
// - Returns true if conversion was handled (even with error), false if
// conversion is unsupported.
func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) {
convertible = true
dstT := dst.Type()
@@ -618,48 +637,80 @@ func substituteEnv(data []byte) ([]byte, gperr.Error) {
return data, nil
}
func UnmarshalValidateYAML[T any](data []byte, target *T) gperr.Error {
type (
marshalFunc func(src any) ([]byte, error)
unmarshalFunc func(data []byte, target any) error
newDecoderFunc func(r io.Reader) interface {
Decode(v any) error
}
interceptFunc func(m map[string]any) gperr.Error
)
// UnmarshalValidate unmarshals data into a map, applies optional intercept
// functions, and validates the result against the target struct using field tags.
// - Environment variables in the data are substituted using ${VAR} syntax.
// - The unmarshaler function converts data to a map[string]any.
// - Intercept functions can modify or validate the map before unmarshaling.
func UnmarshalValidate[T any](data []byte, target *T, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) gperr.Error {
data, err := substituteEnv(data)
if err != nil {
return err
}
m := make(map[string]any)
if err := yaml.Unmarshal(data, &m); err != nil {
if err := unmarshaler(data, &m); err != nil {
return gperr.Wrap(err)
}
for _, intercept := range interceptFns {
if err := intercept(m); err != nil {
return err
}
}
return MapUnmarshalValidate(m, target)
}
func UnmarshalValidateYAMLIntercept[T any](data []byte, target *T, intercept func(m map[string]any) gperr.Error) gperr.Error {
// UnmarshalValidateReader reads from an io.Reader, unmarshals to a map,
// - Applies optional intercept functions, and validates against the target struct.
// - Environment variables are substituted during reading using ${VAR} syntax.
// - The newDecoder function creates a decoder for the reader (e.g.,
// json.NewDecoder).
func UnmarshalValidateReader[T any](reader io.Reader, target *T, newDecoder newDecoderFunc, interceptFns ...interceptFunc) gperr.Error {
m := make(map[string]any)
if err := newDecoder(NewSubstituteEnvReader(reader)).Decode(&m); err != nil {
return gperr.Wrap(err)
}
for _, intercept := range interceptFns {
if err := intercept(m); err != nil {
return err
}
}
return MapUnmarshalValidate(m, target)
}
// UnmarshalValidateXSync unmarshals data into an xsync.Map[string, V].
// - Environment variables in the data are substituted using ${VAR} syntax.
// - The unmarshaler function converts data to a map[string]any.
// - Intercept functions can modify or validate the map before unmarshaling.
// - Returns a thread-safe concurrent map with the unmarshaled values.
func UnmarshalValidateXSync[V any](data []byte, unmarshaler unmarshalFunc, interceptFns ...interceptFunc) (*xsync.Map[string, V], gperr.Error) {
data, err := substituteEnv(data)
if err != nil {
return err
return nil, err
}
m := make(map[string]any)
if err := yaml.Unmarshal(data, &m); err != nil {
return gperr.Wrap(err)
if err := unmarshaler(data, &m); err != nil {
return nil, gperr.Wrap(err)
}
if err := intercept(m); err != nil {
return err
}
return MapUnmarshalValidate(m, target)
}
func UnmarshalValidateYAMLXSync[V any](data []byte) (_ *xsync.Map[string, V], err gperr.Error) {
data, err = substituteEnv(data)
if err != nil {
return
for _, intercept := range interceptFns {
if err := intercept(m); err != nil {
return nil, err
}
}
m := make(map[string]any)
if err = gperr.Wrap(yaml.Unmarshal(data, &m)); err != nil {
return
}
m2 := make(map[string]V, len(m))
if err = MapUnmarshalValidate(m, m2); err != nil {
return
return nil, err
}
ret := xsync.NewMap[string, V](xsync.WithPresize(len(m)))
for k, v := range m2 {
@@ -668,26 +719,27 @@ func UnmarshalValidateYAMLXSync[V any](data []byte) (_ *xsync.Map[string, V], er
return ret, nil
}
func loadSerialized[T any](path string, dst *T, deserialize func(data []byte, dst any) error) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return deserialize(data, dst)
}
func SaveJSON[T any](path string, src *T, perm os.FileMode) error {
data, err := sonic.Marshal(src)
// SaveFile marshals a value to bytes and writes it to a file.
// - The marshaler function converts the value to bytes.
// - The file is written with the specified permissions.
func SaveFile[T any](path string, src *T, perm os.FileMode, marshaler marshalFunc) error {
data, err := marshaler(src)
if err != nil {
return err
}
return os.WriteFile(path, data, perm)
}
func LoadJSONIfExist[T any](path string, dst *T) error {
err := loadSerialized(path, dst, sonic.Unmarshal)
if os.IsNotExist(err) {
return nil
// LoadFileIfExist reads a file and unmarshals its contents to a value.
// - The unmarshaler function converts the bytes to a value.
// - If the file does not exist, nil is returned and dst remains unchanged.
func LoadFileIfExist[T any](path string, dst *T, unmarshaler unmarshalFunc) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return err
return unmarshaler(data, dst)
}

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require"
expect "github.com/yusing/goutils/testing"
)
@@ -303,6 +304,6 @@ autocert:
} `yaml:"options"`
} `yaml:"autocert"`
}
require.NoError(t, UnmarshalValidateYAML(data, &cfg))
require.NoError(t, UnmarshalValidate(data, &cfg, yaml.Unmarshal))
require.Equal(t, "test", cfg.Autocert.Options.AuthToken)
}

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