Compare commits

...

12 Commits

Author SHA1 Message Date
Yuzerion
1543ffa19f Create CODE_OF_CONDUCT.md 2026-01-28 16:24:06 +08:00
yusing
730e3a2ab4 fix(docker): improve error handling for missing Docker agent
Replaced panic with an error return in the NewClient
2026-01-27 00:37:55 +08:00
yusing
ba4af8fe77 refactor(proxmox): add validation for node name and VMID in provider initialization 2026-01-27 00:02:25 +08:00
yusing
b788e6e338 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-27 00:01:48 +08:00
yusing
ef3aa146b5 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-26 23:51:18 +08:00
yusing
e222e693d7 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-26 21:09:47 +08:00
yusing
277a485afe 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-26 14:17:41 +08:00
yusing
211c466fc3 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-25 22:21:35 +08:00
yusing
f96884c62b 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-25 22:19:26 +08:00
yusing
8b4f10f15a 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-25 19:55:11 +08:00
yusing
6c9b1fe45c refactor(swagger): rename DockerConfig and ProxmoxNodeConfig to IdlewatcherDockerConfig and IdlewatcherProxmoxNodeConfig 2026-01-25 19:28:01 +08:00
yusing
73cba8b508 refactor: improve error handling, validation and proper cleanup 2026-01-25 19:18:14 +08:00
29 changed files with 1113 additions and 269 deletions

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/**

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
yusing@6uo.me.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

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...")
}
}()

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

@@ -146,6 +146,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

@@ -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"

View File

@@ -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",
@@ -3389,33 +3640,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 +4388,7 @@
"x-omitempty": false
},
"docker": {
"$ref": "#/definitions/DockerConfig",
"$ref": "#/definitions/IdlewatcherDockerConfig",
"x-nullable": false,
"x-omitempty": false
},
@@ -4184,7 +4408,7 @@
"x-omitempty": false
},
"proxmox": {
"$ref": "#/definitions/ProxmoxNodeConfig",
"$ref": "#/definitions/IdlewatcherProxmoxNodeConfig",
"x-nullable": false,
"x-omitempty": false
},
@@ -4218,6 +4442,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 +5077,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,

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:
@@ -1792,12 +1807,12 @@ definitions:
type: string
kernel_version:
type: string
load_avg_15m:
type: string
load_avg_1m:
type: string
load_avg_5m:
type: string
load_avg_15m:
type: string
mem_pct:
type: string
mem_total:
@@ -3392,6 +3407,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 +3472,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 +3535,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 +3567,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 +3602,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 +3852,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:

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

@@ -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

@@ -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

@@ -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),

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

@@ -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

@@ -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

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"net/url"
"runtime"
"slices"
"strconv"
@@ -21,6 +22,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 +46,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 +72,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

View File

@@ -0,0 +1,59 @@
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 --retry ")
for _, file := range files {
fmt.Fprintf(&command, " %q ", file)
}
if limit > 0 {
fmt.Fprintf(&command, " -n %d", limit)
}
return 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,23 @@ 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.
// 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)
}
if limit > 0 {
command = fmt.Sprintf("%s -n %d", command, limit)
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
}
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

@@ -10,10 +10,11 @@ import (
)
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 {

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

@@ -185,21 +185,66 @@ 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 {
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 r.Proxmox != nil {
nodeName := r.Proxmox.Node
vmid := r.Proxmox.VMID
if nodeName == "" {
if nodeName == "" || vmid == nil {
return gperr.Errorf("node (proxmox node name) is required")
}
@@ -208,9 +253,19 @@ func (r *Route) validate() gperr.Error {
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)
// 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 {
return gperr.Wrap(err) // ErrResourceNotFound
}
@@ -218,7 +273,7 @@ func (r *Route) validate() gperr.Error {
r.Proxmox.VMName = res.Name
if r.Host == DefaultHost {
containerName := r.Idlewatcher.ContainerName()
containerName := res.Name
// get ip addresses of the vmid
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -235,14 +290,14 @@ func (r *Route) validate() gperr.Error {
l := log.With().Str("container", containerName).Logger()
l.Info().Msg("checking if container is running")
running, err := node.LXCIsRunning(ctx, vmid)
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 {
if err := node.LXCAction(ctx, *vmid, proxmox.LXCStart); err != nil {
return gperr.New("failed to start container").With(err)
}
}
@@ -336,45 +391,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")
}
@@ -556,11 +572,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

@@ -41,11 +41,11 @@ type (
DockerCfg DockerProviderConfig `json:"docker_cfg" validate:"required"`
ContainerID string `json:"container_id" validate:"required"`
ContainerName string `json:"container_name" validate:"required"`
} // @name DockerConfig
} // @name IdlewatcherDockerConfig
ProxmoxConfig struct {
Node string `json:"node" validate:"required"`
VMID int `json:"vmid" validate:"required"`
} // @name ProxmoxNodeConfig
} // @name IdlewatcherProxmoxNodeConfig
)
const (