mirror of
https://github.com/yusing/godoxy.git
synced 2026-02-01 14:43:39 +01:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb96a2a4f1 | ||
|
|
fdfb682e2a | ||
|
|
8d56c61826 | ||
|
|
d1fca7e987 | ||
|
|
95f88a6f3c | ||
|
|
c0e2cf63b5 | ||
|
|
6388d07f64 | ||
|
|
15e50322c9 | ||
|
|
3ad6e98a17 | ||
|
|
3b0484f4a5 | ||
|
|
6528fb0a8d | ||
|
|
0f13004ad6 | ||
|
|
d39660e6fa | ||
|
|
4c7d52d89d | ||
|
|
28fd502bd7 | ||
|
|
0716e80345 | ||
|
|
372132b1da | ||
|
|
06be1744ae | ||
|
|
6c6e13704e | ||
|
|
d34b62e2f5 | ||
|
|
e6bd7c2462 | ||
|
|
8b985654ef | ||
|
|
1543ffa19f | ||
|
|
730e3a2ab4 | ||
|
|
ba4af8fe77 | ||
|
|
b788e6e338 | ||
|
|
ef3aa146b5 | ||
|
|
e222e693d7 | ||
|
|
277a485afe | ||
|
|
211c466fc3 | ||
|
|
f96884c62b | ||
|
|
8b4f10f15a | ||
|
|
6c9b1fe45c | ||
|
|
73cba8b508 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -40,4 +40,8 @@ tsconfig.tsbuildinfo
|
||||
|
||||
!agent.compose.yml
|
||||
!agent/pkg/**
|
||||
dev-data/
|
||||
dev-data/
|
||||
|
||||
RELEASE_NOTES.md
|
||||
CLAUDE.md
|
||||
.kilocode/**
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
yusing@6uo.me.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
2
Makefile
2
Makefile
@@ -92,7 +92,7 @@ docker-build-test:
|
||||
|
||||
go_ver := $(shell go version | cut -d' ' -f3 | cut -d'o' -f2)
|
||||
files := $(shell find . -name go.mod -type f -or -name Dockerfile -type f)
|
||||
gomod_paths := $(shell find . -name go.mod -type f | xargs dirname)
|
||||
gomod_paths := $(shell find . -name go.mod -type f | grep -vE '^./internal/(go-oidc|go-proxmox|gopsutil)/' | xargs dirname)
|
||||
|
||||
update-go:
|
||||
for file in ${files}; do \
|
||||
|
||||
13
agent/go.mod
13
agent/go.mod
@@ -2,6 +2,11 @@ module github.com/yusing/godoxy/agent
|
||||
|
||||
go 1.25.6
|
||||
|
||||
exclude (
|
||||
github.com/moby/moby/api v1.53.0 // allow older daemon versions
|
||||
github.com/moby/moby/client v0.2.2 // allow older daemon versions
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/shirou/gopsutil/v4 => ../internal/gopsutil
|
||||
github.com/yusing/godoxy => ../
|
||||
@@ -22,7 +27,7 @@ require (
|
||||
github.com/pion/transport/v3 v3.1.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/yusing/godoxy v0.25.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
|
||||
)
|
||||
@@ -38,7 +43,7 @@ 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/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
@@ -86,8 +91,8 @@ require (
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/yusing/ds v0.4.1 // indirect
|
||||
github.com/yusing/gointernals v0.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
|
||||
|
||||
12
agent/go.sum
12
agent/go.sum
@@ -37,8 +37,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/docker/cli v29.1.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/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 +82,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=
|
||||
@@ -153,8 +153,8 @@ 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/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=
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/godoxy/agent/pkg/agent/common"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -356,7 +366,7 @@ func (cfg *AgentConfig) fetchJSON(ctx context.Context, endpoint string, out any)
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, out)
|
||||
err = sonic.Unmarshal(data, out)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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...")
|
||||
}
|
||||
}()
|
||||
|
||||
29
go.mod
29
go.mod
@@ -2,6 +2,11 @@ module github.com/yusing/godoxy
|
||||
|
||||
go 1.25.6
|
||||
|
||||
exclude (
|
||||
github.com/moby/moby/api v1.53.0 // allow older daemon versions
|
||||
github.com/moby/moby/client v0.2.2 // allow older daemon versions
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/coreos/go-oidc/v3 => ./internal/go-oidc
|
||||
github.com/luthermonson/go-proxmox => ./internal/go-proxmox
|
||||
@@ -25,7 +30,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
|
||||
@@ -39,9 +44,9 @@ 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/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/golang-jwt/jwt/v5 v5.3.1 // 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
|
||||
@@ -52,13 +57,13 @@ require (
|
||||
github.com/stretchr/testify v1.11.1 // testing framework
|
||||
github.com/valyala/fasthttp v1.69.0 // fast http for health check
|
||||
github.com/yusing/ds v0.4.1 // data structures and algorithms
|
||||
github.com/yusing/godoxy/agent v0.0.0-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 (
|
||||
@@ -136,8 +141,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
|
||||
@@ -170,8 +175,8 @@ require (
|
||||
github.com/linode/linodego v1.64.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/nrdcg/goinwx v0.12.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.106.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
|
||||
|
||||
28
go.sum
28
go.sum
@@ -76,8 +76,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/docker/cli v29.1.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/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 +137,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=
|
||||
@@ -227,10 +227,10 @@ github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0
|
||||
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
|
||||
github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
|
||||
github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.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,8 +251,8 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pires/go-proxyproto v0.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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -447,14 +447,14 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.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=
|
||||
|
||||
2
goutils
2
goutils
Submodule goutils updated: 272bc53439...52ea531e95
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -86,6 +86,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 +148,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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -36,6 +37,9 @@ type VerifyNewAgentRequest struct {
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /agent/verify [post]
|
||||
func Verify(c *gin.Context) {
|
||||
// avoid timeout waiting for response headers
|
||||
c.Status(http.StatusContinue)
|
||||
|
||||
var request VerifyNewAgentRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
|
||||
@@ -60,7 +64,7 @@ func Verify(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
nRoutesAdded, err := verifyNewAgent(request.Host, ca, client, request.ContainerRuntime)
|
||||
nRoutesAdded, err := verifyNewAgent(c.Request.Context(), request.Host, ca, client, request.ContainerRuntime)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
|
||||
return
|
||||
@@ -82,7 +86,7 @@ func Verify(c *gin.Context) {
|
||||
|
||||
var errAgentAlreadyExists = gperr.New("agent already exists")
|
||||
|
||||
func verifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair, containerRuntime agent.ContainerRuntime) (int, gperr.Error) {
|
||||
func verifyNewAgent(ctx context.Context, host string, ca agent.PEMPair, client agent.PEMPair, containerRuntime agent.ContainerRuntime) (int, gperr.Error) {
|
||||
var agentCfg agent.AgentConfig
|
||||
agentCfg.Addr = host
|
||||
agentCfg.Runtime = containerRuntime
|
||||
@@ -99,7 +103,7 @@ func verifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair, contain
|
||||
return 0, errAgentAlreadyExists
|
||||
}
|
||||
|
||||
err := agentCfg.InitWithCerts(cfgState.Context(), ca.Cert, client.Cert, client.Key)
|
||||
err := agentCfg.InitWithCerts(ctx, ca.Cert, client.Cert, client.Key)
|
||||
if err != nil {
|
||||
return 0, gperr.Wrap(err, "failed to initialize agent config")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,4 +3,4 @@ package proxmoxapi
|
||||
type ActionRequest struct {
|
||||
Node string `uri:"node" binding:"required"`
|
||||
VMID int `uri:"vmid" binding:"required"`
|
||||
}
|
||||
} // @name ProxmoxVMActionRequest
|
||||
|
||||
@@ -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 {
|
||||
|
||||
77
internal/api/v1/proxmox/tail.go
Normal file
77
internal/api/v1/proxmox/tail.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package proxmoxapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yusing/godoxy/internal/proxmox"
|
||||
"github.com/yusing/goutils/apitypes"
|
||||
"github.com/yusing/goutils/http/websocket"
|
||||
)
|
||||
|
||||
// e.g. ws://localhost:8889/api/v1/proxmox/tail?node=pve&vmid=127&file=/var/log/immich/web.log&file=/var/log/immich/ml.log&limit=10
|
||||
|
||||
type TailRequest struct {
|
||||
Node string `form:"node" binding:"required"` // Node name
|
||||
VMID *int `form:"vmid"` // Container VMID (optional - if not provided, streams node journalctl)
|
||||
Files []string `form:"file" binding:"required,dive,filepath"` // File paths
|
||||
Limit int `form:"limit" default:"100" binding:"min=1,max=1000"` // Limit output lines (1-1000)
|
||||
} // @name ProxmoxTailRequest
|
||||
|
||||
// @x-id "tail"
|
||||
// @BasePath /api/v1
|
||||
// @Summary Get tail output
|
||||
// @Description Get tail output for node or LXC container. If vmid is not provided, streams node tail.
|
||||
// @Tags proxmox,websocket
|
||||
// @Accept json
|
||||
// @Produce application/json
|
||||
// @Param query query TailRequest true "Request"
|
||||
// @Success 200 string plain "Tail output"
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
|
||||
// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized"
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
|
||||
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
|
||||
// @Router /proxmox/tail [get]
|
||||
func Tail(c *gin.Context) {
|
||||
var request TailRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
|
||||
return
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(request.Node)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusContinue)
|
||||
|
||||
var reader io.ReadCloser
|
||||
var err error
|
||||
if request.VMID == nil {
|
||||
reader, err = node.NodeTail(c.Request.Context(), request.Files, request.Limit)
|
||||
} else {
|
||||
reader, err = node.LXCTail(c.Request.Context(), *request.VMID, request.Files, request.Limit)
|
||||
}
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to get journalctl output"))
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
manager, err := websocket.NewManagerWithUpgrade(c)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
|
||||
return
|
||||
}
|
||||
defer manager.Close()
|
||||
|
||||
writer := manager.NewWriter(websocket.TextMessage)
|
||||
_, err = io.Copy(writer, reader)
|
||||
if err != nil {
|
||||
c.Error(apitypes.InternalServerError(err, "failed to copy journalctl output"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
69
internal/api/v1/route/validate.go
Normal file
69
internal/api/v1/route/validate.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
@@ -27,7 +30,7 @@ type Config struct {
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
KeyPath string `json:"key_path,omitempty"`
|
||||
Extra []ConfigExtra `json:"extra,omitempty"`
|
||||
ACMEKeyPath string `json:"acme_key_path,omitempty"` // shared by all extra providers
|
||||
ACMEKeyPath string `json:"acme_key_path,omitempty"` // shared by all extra providers with the same CA directory URL
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Options map[string]strutils.Redacted `json:"options,omitempty"`
|
||||
|
||||
@@ -88,7 +91,7 @@ func (cfg *Config) validate(seenPaths map[string]int) gperr.Error {
|
||||
cfg.KeyPath = KeyFileDefault
|
||||
}
|
||||
if cfg.ACMEKeyPath == "" {
|
||||
cfg.ACMEKeyPath = ACMEKeyFileDefault
|
||||
cfg.ACMEKeyPath = acmeKeyPath(cfg.CADirURL)
|
||||
}
|
||||
|
||||
b := gperr.NewBuilder("certificate error")
|
||||
@@ -272,3 +275,16 @@ func (cfg *Config) SaveACMEKey(key *ecdsa.PrivateKey) error {
|
||||
}
|
||||
return os.WriteFile(cfg.ACMEKeyPath, data, 0o600)
|
||||
}
|
||||
|
||||
// acmeKeyPath returns the path to the ACME key file based on the CA directory URL.
|
||||
// Different CA directory URLs will use different key files to avoid key conflicts.
|
||||
func acmeKeyPath(caDirURL string) string {
|
||||
// Use a hash of the CA directory URL to create a unique key filename
|
||||
// Default to "acme" if no custom CA is configured (Let's Encrypt default)
|
||||
filename := "acme"
|
||||
if caDirURL != "" {
|
||||
hash := sha256.Sum256([]byte(caDirURL))
|
||||
filename = "acme_" + hex.EncodeToString(hash[:])[:16]
|
||||
}
|
||||
return filepath.Join(certBasePath, filename+".key")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package autocert
|
||||
|
||||
const (
|
||||
certBasePath = "certs/"
|
||||
CertFileDefault = certBasePath + "cert.crt"
|
||||
KeyFileDefault = certBasePath + "priv.key"
|
||||
ACMEKeyFileDefault = certBasePath + "acme.key"
|
||||
certBasePath = "certs/"
|
||||
CertFileDefault = certBasePath + "cert.crt"
|
||||
KeyFileDefault = certBasePath + "priv.key"
|
||||
)
|
||||
|
||||
@@ -222,13 +222,14 @@ func (p *Provider) ObtainCertIfNotExistsAll() error {
|
||||
})
|
||||
}
|
||||
|
||||
err := errs.Wait().Error()
|
||||
p.rebuildSNIMatcher()
|
||||
return errs.Wait().Error()
|
||||
return err
|
||||
}
|
||||
|
||||
// obtainCertIfNotExists obtains a new certificate for this provider if it does not exist.
|
||||
func (p *Provider) obtainCertIfNotExists() error {
|
||||
err := p.LoadCert()
|
||||
err := p.loadCert()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -261,7 +262,10 @@ func (p *Provider) ObtainCertAll() error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return errs.Wait().Error()
|
||||
|
||||
err := errs.Wait().Error()
|
||||
p.rebuildSNIMatcher()
|
||||
return err
|
||||
}
|
||||
|
||||
// ObtainCert renews existing certificate or obtains a new certificate for this provider.
|
||||
@@ -346,29 +350,32 @@ func (p *Provider) ObtainCert() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) LoadCert() error {
|
||||
func (p *Provider) LoadCertAll() error {
|
||||
var errs gperr.Builder
|
||||
for _, provider := range p.allProviders() {
|
||||
if err := provider.loadCert(); err != nil {
|
||||
errs.Add(provider.fmtError(err))
|
||||
}
|
||||
}
|
||||
p.rebuildSNIMatcher()
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
func (p *Provider) loadCert() error {
|
||||
cert, err := tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath)
|
||||
if err != nil {
|
||||
errs.Addf("load SSL certificate: %w", p.fmtError(err))
|
||||
return err
|
||||
}
|
||||
|
||||
expiries, err := getCertExpiries(&cert)
|
||||
if err != nil {
|
||||
errs.Addf("parse SSL certificate: %w", p.fmtError(err))
|
||||
return err
|
||||
}
|
||||
|
||||
p.tlsCert = &cert
|
||||
p.certExpiries = expiries
|
||||
|
||||
for _, ep := range p.extraProviders {
|
||||
if err := ep.LoadCert(); err != nil {
|
||||
errs.Add(err)
|
||||
}
|
||||
}
|
||||
|
||||
p.rebuildSNIMatcher()
|
||||
return errs.Error()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintCertExpiriesAll prints the certificate expiries for this provider and all extra providers.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "a.internal.example.com"})
|
||||
@@ -113,7 +113,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
|
||||
@@ -145,7 +145,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "unknown.domain.com"})
|
||||
@@ -171,7 +171,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(nil)
|
||||
@@ -197,7 +197,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: ""})
|
||||
@@ -229,7 +229,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "FOO.EXAMPLE.COM"})
|
||||
@@ -261,7 +261,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: " foo.example.com. "})
|
||||
@@ -293,7 +293,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.a.example.com"})
|
||||
@@ -319,7 +319,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "bar.example.com"})
|
||||
@@ -355,7 +355,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert1, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.test.com"})
|
||||
@@ -392,7 +392,7 @@ func TestGetCertBySNI(t *testing.T) {
|
||||
p, err := autocert.NewProvider(cfg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = p.LoadCert()
|
||||
err = p.LoadCertAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
cert1, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
|
||||
|
||||
@@ -3,6 +3,7 @@ package autocert_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/autocert"
|
||||
"github.com/yusing/godoxy/internal/dnsproviders"
|
||||
@@ -42,7 +43,7 @@ extra:
|
||||
`
|
||||
|
||||
var cfg autocert.Config
|
||||
err := error(serialization.UnmarshalValidateYAML([]byte(cfgYAML), &cfg))
|
||||
err := error(serialization.UnmarshalValidate([]byte(cfgYAML), &cfg, yaml.Unmarshal))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test: extra[0] inherits all fields from main except CertPath and KeyPath.
|
||||
|
||||
@@ -13,6 +13,8 @@ var (
|
||||
IsDebug = env.GetEnvBool("DEBUG", IsTest)
|
||||
IsTrace = env.GetEnvBool("TRACE", false) && IsDebug
|
||||
|
||||
InitTimeout = env.GetEnvDuation("INIT_TIMEOUT", 1*time.Minute)
|
||||
|
||||
ShortLinkPrefix = env.GetEnvString("SHORTLINK_PREFIX", "go")
|
||||
|
||||
ProxyHTTPAddr,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
@@ -44,7 +44,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
|
||||
@@ -65,8 +65,8 @@ require (
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/nrdcg/goacmedns v0.2.0 // indirect
|
||||
github.com/nrdcg/goinwx v0.12.0 // indirect
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.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
|
||||
@@ -98,8 +98,8 @@ require (
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/api v0.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
|
||||
|
||||
@@ -90,8 +90,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=
|
||||
@@ -150,10 +150,10 @@ github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0
|
||||
github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg=
|
||||
github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4=
|
||||
github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0=
|
||||
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.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=
|
||||
@@ -249,14 +249,14 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.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=
|
||||
|
||||
@@ -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),
|
||||
|
||||
Submodule internal/go-proxmox updated: bcae3065be...7a07c21f07
@@ -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 {
|
||||
|
||||
@@ -12,6 +12,14 @@ import (
|
||||
)
|
||||
|
||||
func Stream(ctx context.Context, url *url.URL, timeout time.Duration) (types.HealthCheckResult, error) {
|
||||
if port := url.Port(); port == "" || port == "0" {
|
||||
return types.HealthCheckResult{
|
||||
Latency: 0,
|
||||
Healthy: false,
|
||||
Detail: "no port specified",
|
||||
}, nil
|
||||
}
|
||||
|
||||
dialer := net.Dialer{
|
||||
Timeout: timeout,
|
||||
FallbackDelay: -1,
|
||||
|
||||
@@ -55,20 +55,20 @@ func init() {
|
||||
|
||||
func InitCache() {
|
||||
m := make(IconMap)
|
||||
err := serialization.LoadJSONIfExist(common.IconListCachePath, &m)
|
||||
err := serialization.LoadFileIfExist(common.IconListCachePath, &m, sonic.Unmarshal)
|
||||
if err != nil {
|
||||
// backward compatible
|
||||
oldFormat := struct {
|
||||
Icons IconMap
|
||||
LastUpdate time.Time
|
||||
}{}
|
||||
err = serialization.LoadJSONIfExist(common.IconListCachePath, &oldFormat)
|
||||
err = serialization.LoadFileIfExist(common.IconListCachePath, &oldFormat, sonic.Unmarshal)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to load icons")
|
||||
} else {
|
||||
m = oldFormat.Icons
|
||||
// store it to disk immediately
|
||||
_ = serialization.SaveJSON(common.IconListCachePath, &m, 0o644)
|
||||
_ = serialization.SaveFile(common.IconListCachePath, &m, 0o644, sonic.Marshal)
|
||||
}
|
||||
} else if len(m) > 0 {
|
||||
log.Info().
|
||||
@@ -84,7 +84,7 @@ func InitCache() {
|
||||
|
||||
task.OnProgramExit("save_icons_cache", func() {
|
||||
icons := iconsCache.Load()
|
||||
_ = serialization.SaveJSON(common.IconListCachePath, &icons, 0o644)
|
||||
_ = serialization.SaveFile(common.IconListCachePath, &icons, 0o644, sonic.Marshal)
|
||||
})
|
||||
|
||||
go backgroundUpdateIcons()
|
||||
@@ -105,7 +105,7 @@ func backgroundUpdateIcons() {
|
||||
// swap old cache with new cache
|
||||
iconsCache.Store(newCache)
|
||||
// save it to disk
|
||||
err := serialization.SaveJSON(common.IconListCachePath, &newCache, 0o644)
|
||||
err := serialization.SaveFile(common.IconListCachePath, &newCache, 0o644, sonic.Marshal)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("failed to save icons")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ const proxmoxStateCheckInterval = 1 * time.Second
|
||||
var ErrNodeNotFound = gperr.New("node not found in pool")
|
||||
|
||||
func NewProxmoxProvider(ctx context.Context, nodeName string, vmid int) (idlewatcher.Provider, error) {
|
||||
if nodeName == "" || vmid == 0 {
|
||||
return nil, gperr.New("node name and vmid are required")
|
||||
}
|
||||
|
||||
node, ok := proxmox.Nodes.Get(nodeName)
|
||||
if !ok {
|
||||
return nil, ErrNodeNotFound.Subject(nodeName).
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package idlewatcher
|
||||
@@ -83,7 +83,8 @@ func loadNS[T store](ns namespace) T {
|
||||
func save() error {
|
||||
errs := gperr.NewBuilder("failed to save data stores")
|
||||
for ns, store := range stores {
|
||||
if err := serialization.SaveJSON(filepath.Join(storesPath, string(ns)+".json"), &store, 0o644); err != nil {
|
||||
path := filepath.Join(storesPath, string(ns)+".json")
|
||||
if err := serialization.SaveFile(path, &store, 0o644, sonic.Marshal); err != nil {
|
||||
errs.Add(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -13,7 +14,6 @@ import (
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
60
internal/proxmox/command_common.go
Normal file
60
internal/proxmox/command_common.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// checkValidInput checks if the input contains invalid characters.
|
||||
//
|
||||
// The characters are: & | $ ; ' " ` $( ${ < >
|
||||
// These characters are used in the command line to escape the input or to expand variables.
|
||||
// We need to check if the input contains these characters and return an error if it does.
|
||||
// This is to prevent command injection.
|
||||
func checkValidInput(input string) error {
|
||||
if strings.ContainsAny(input, "&|$;'\"`<>") {
|
||||
return fmt.Errorf("input contains invalid characters: %q", input)
|
||||
}
|
||||
if strings.Contains(input, "$(") {
|
||||
return fmt.Errorf("input contains $(: %q", input)
|
||||
}
|
||||
if strings.Contains(input, "${") {
|
||||
return fmt.Errorf("input contains ${: %q", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTail(files []string, limit int) (string, error) {
|
||||
for _, file := range files {
|
||||
if err := checkValidInput(file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
var command strings.Builder
|
||||
command.WriteString("tail -f -q ")
|
||||
for _, file := range files {
|
||||
fmt.Fprintf(&command, " %q ", file)
|
||||
}
|
||||
if limit > 0 {
|
||||
fmt.Fprintf(&command, " -n %d", limit)
|
||||
}
|
||||
// try --retry first, if it fails, try the command again
|
||||
return fmt.Sprintf("sh -c '%s --retry 2>/dev/null || %s'", command.String(), command.String()), nil
|
||||
}
|
||||
|
||||
func formatJournalctl(services []string, limit int) (string, error) {
|
||||
for _, service := range services {
|
||||
if err := checkValidInput(service); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
var command strings.Builder
|
||||
command.WriteString("journalctl -f")
|
||||
for _, service := range services {
|
||||
fmt.Fprintf(&command, " -u %q ", service)
|
||||
}
|
||||
if limit > 0 {
|
||||
fmt.Fprintf(&command, " -n %d", limit)
|
||||
}
|
||||
return command.String(), nil
|
||||
}
|
||||
@@ -4,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@ import (
|
||||
"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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
56
internal/proxmox/validation_test.go
Normal file
56
internal/proxmox/validation_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
)
|
||||
|
||||
func TestValidateCommandArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlCfg string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid_services",
|
||||
yamlCfg: `services: ["foo", "bar"]`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid_services",
|
||||
yamlCfg: `services: ["foo", "bar & baz"]`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid_services_with_$(",
|
||||
yamlCfg: `services: ["foo", "bar & $(echo 'hello')"]`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid_files",
|
||||
yamlCfg: `files: ["foo", "bar"]`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid_files",
|
||||
yamlCfg: `files: ["foo", "bar & baz"]`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var cfg NodeConfig
|
||||
err := serialization.UnmarshalValidate([]byte(tt.yamlCfg), &cfg, yaml.Unmarshal)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "input contains invalid characters")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
@@ -43,7 +44,7 @@ func removeXPrefix(m map[string]any) gperr.Error {
|
||||
}
|
||||
|
||||
func validate(data []byte) (routes route.Routes, err gperr.Error) {
|
||||
err = serialization.UnmarshalValidateYAMLIntercept(data, &routes, removeXPrefix)
|
||||
err = serialization.UnmarshalValidate(data, &routes, yaml.Unmarshal, removeXPrefix)
|
||||
return routes, err
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -273,7 +254,7 @@ func (r *Route) validate() gperr.Error {
|
||||
}
|
||||
|
||||
// return error if route is localhost:<godoxy_port> but route is not agent
|
||||
if !r.IsAgent() {
|
||||
if !r.IsAgent() && !r.ShouldExclude() {
|
||||
switch r.Host {
|
||||
case "localhost", "127.0.0.1":
|
||||
switch r.Port.Proxy {
|
||||
@@ -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}
|
||||
@@ -723,6 +749,7 @@ const (
|
||||
ExcludedReasonNoPortSpecified
|
||||
ExcludedReasonBlacklisted
|
||||
ExcludedReasonBuildx
|
||||
ExcludedReasonYAMLAnchor
|
||||
ExcludedReasonOld
|
||||
)
|
||||
|
||||
@@ -742,6 +769,8 @@ func (re ExcludedReason) String() string {
|
||||
return "Blacklisted (backend service or database)"
|
||||
case ExcludedReasonBuildx:
|
||||
return "Buildx"
|
||||
case ExcludedReasonYAMLAnchor:
|
||||
return "YAML anchor or reference"
|
||||
case ExcludedReasonOld:
|
||||
return "Container renaming intermediate state"
|
||||
default:
|
||||
@@ -776,6 +805,12 @@ func (r *Route) findExcludedReason() ExcludedReason {
|
||||
} else if r.IsZeroPort() && r.Scheme != route.SchemeFileServer {
|
||||
return ExcludedReasonNoPortSpecified
|
||||
}
|
||||
// this should happen on validation API only,
|
||||
// those routes are removed before validation.
|
||||
// see removeXPrefix in provider/file.go
|
||||
if strings.HasPrefix(r.Alias, "x-") { // for YAML anchors and references
|
||||
return ExcludedReasonYAMLAnchor
|
||||
}
|
||||
if strings.HasSuffix(r.Alias, "-old") {
|
||||
return ExcludedReasonOld
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -115,24 +115,6 @@ func validateURL(args []string) (any, gperr.Error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// validateAbsoluteURL returns types.URL with the URL validated.
|
||||
func validateAbsoluteURL(args []string) (any, gperr.Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, ErrExpectOneArg
|
||||
}
|
||||
u, err := nettypes.ParseURL(args[0])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "http"
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, ErrInvalidArguments.Withf("missing host")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// validateCIDR returns types.CIDR with the CIDR validated.
|
||||
func validateCIDR(args []string) (any, gperr.Error) {
|
||||
if len(args) != 1 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
37
internal/serialization/gin_binding.go
Normal file
37
internal/serialization/gin_binding.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
type (
|
||||
GinJSONBinding struct{}
|
||||
GinYAMLBinding struct{}
|
||||
)
|
||||
|
||||
func (b GinJSONBinding) Name() string {
|
||||
return "json"
|
||||
}
|
||||
|
||||
func (b GinJSONBinding) Bind(req *http.Request, obj any) error {
|
||||
m := make(map[string]any)
|
||||
if err := sonic.ConfigDefault.NewDecoder(NewSubstituteEnvReader(req.Body)).Decode(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
return MapUnmarshalValidate(m, obj)
|
||||
}
|
||||
|
||||
func (b GinYAMLBinding) Name() string {
|
||||
return "yaml"
|
||||
}
|
||||
|
||||
func (b GinYAMLBinding) Bind(req *http.Request, obj any) error {
|
||||
m := make(map[string]any)
|
||||
if err := yaml.NewDecoder(NewSubstituteEnvReader(req.Body)).Decode(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
return MapUnmarshalValidate(m, obj)
|
||||
}
|
||||
50
internal/serialization/gin_binding_test.go
Normal file
50
internal/serialization/gin_binding_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package serialization_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
)
|
||||
|
||||
type TestStruct struct {
|
||||
Value string `json:"value"`
|
||||
Value2 int `json:"value2"`
|
||||
}
|
||||
|
||||
func (t *TestStruct) Validate() gperr.Error {
|
||||
if t.Value == "" {
|
||||
return gperr.New("value is required")
|
||||
}
|
||||
if t.Value2 != 0 && (t.Value2 < 5 || t.Value2 > 10) {
|
||||
return gperr.New("value2 must be between 5 and 10")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGinBinding(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid1", `{"value": "test", "value2": 7}`, false},
|
||||
{"valid2", `{"value": "test"}`, false},
|
||||
{"invalid1", `{"value2": 7}`, true},
|
||||
{"invalid2", `{"value": "test", "value2": 3}`, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var dst TestStruct
|
||||
body := bytes.NewBufferString(tt.input)
|
||||
req := httptest.NewRequest("POST", "/", body)
|
||||
err := serialization.GinJSONBinding{}.Bind(req, &dst)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("%s: Bind() error = %v, wantErr %v", tt.name, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
146
internal/serialization/reader.go
Normal file
146
internal/serialization/reader.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
)
|
||||
|
||||
type SubstituteEnvReader struct {
|
||||
reader io.Reader
|
||||
buf []byte // buffered data with substitutions applied
|
||||
err error // sticky error
|
||||
}
|
||||
|
||||
func NewSubstituteEnvReader(reader io.Reader) *SubstituteEnvReader {
|
||||
return &SubstituteEnvReader{reader: reader}
|
||||
}
|
||||
|
||||
const peekSize = 4096
|
||||
const maxVarNameLength = 256
|
||||
|
||||
func (r *SubstituteEnvReader) Read(p []byte) (n int, err error) {
|
||||
// Return buffered data first
|
||||
if len(r.buf) > 0 {
|
||||
n = copy(p, r.buf)
|
||||
r.buf = r.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Return sticky error if we have one
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
var buf [2 * peekSize]byte
|
||||
|
||||
// Read a chunk from the underlying reader
|
||||
chunk, more := buf[:peekSize], buf[peekSize:]
|
||||
nRead, readErr := r.reader.Read(chunk)
|
||||
if nRead == 0 {
|
||||
if readErr != nil {
|
||||
return 0, readErr
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
chunk = chunk[:nRead]
|
||||
|
||||
// Check if there's a potential incomplete pattern at the end
|
||||
// Pattern: ${VAR_NAME}
|
||||
// We need to check if chunk ends with a partial pattern like "$", "${", "${VAR", etc.
|
||||
incompleteStart := findIncompletePatternStart(chunk)
|
||||
|
||||
if incompleteStart >= 0 && readErr == nil {
|
||||
// There might be an incomplete pattern, read more to complete it
|
||||
incomplete := chunk[incompleteStart:]
|
||||
chunk = chunk[:incompleteStart]
|
||||
|
||||
// Keep reading until we complete the pattern or hit EOF/error
|
||||
for {
|
||||
// Limit how much we buffer to prevent memory exhaustion
|
||||
if len(incomplete) > maxVarNameLength+3 { // ${} + var name
|
||||
// Pattern too long to be valid, give up and process as-is
|
||||
chunk = append(chunk, incomplete...)
|
||||
break
|
||||
}
|
||||
nMore, moreErr := r.reader.Read(more)
|
||||
if nMore > 0 {
|
||||
incomplete = append(incomplete, more[:nMore]...)
|
||||
// Check if pattern is now complete
|
||||
if idx := bytes.IndexByte(incomplete, '}'); idx >= 0 {
|
||||
// Pattern complete, append the rest back to chunk
|
||||
chunk = append(chunk, incomplete...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if moreErr != nil {
|
||||
// No more data, append whatever we have
|
||||
chunk = append(chunk, incomplete...)
|
||||
readErr = moreErr
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
substituted, subErr := substituteEnv(chunk)
|
||||
if subErr != nil {
|
||||
r.err = subErr
|
||||
return 0, subErr
|
||||
}
|
||||
|
||||
n = copy(p, substituted)
|
||||
if n < len(substituted) {
|
||||
// Buffer the rest
|
||||
r.buf = substituted[n:]
|
||||
}
|
||||
|
||||
// Store sticky error for next read
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
r.err = readErr
|
||||
} else {
|
||||
if readErr == io.EOF && n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
return n, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// findIncompletePatternStart returns the index where an incomplete ${...} pattern starts,
|
||||
// or -1 if there's no incomplete pattern at the end.
|
||||
func findIncompletePatternStart(data []byte) int {
|
||||
// Look for '$' near the end that might be start of ${VAR}
|
||||
// Maximum var name we reasonably expect + "${}" = ~256 chars
|
||||
searchStart := max(0, len(data)-maxVarNameLength)
|
||||
|
||||
for i := len(data) - 1; i >= searchStart; i-- {
|
||||
if data[i] == '$' {
|
||||
// Check if this is a complete pattern or incomplete
|
||||
if i+1 >= len(data) {
|
||||
// Just "$" at end
|
||||
return i
|
||||
}
|
||||
if data[i+1] == '{' {
|
||||
// Check if there's anything after "${"
|
||||
if i+2 >= len(data) {
|
||||
// Just "${" at end
|
||||
return i
|
||||
}
|
||||
// Check if pattern is complete by looking for '}'
|
||||
for j := i + 2; j < len(data); j++ {
|
||||
if data[j] == '}' {
|
||||
// This pattern is complete, continue searching for another
|
||||
break
|
||||
}
|
||||
if j == len(data)-1 {
|
||||
// Reached end without finding '}', incomplete pattern
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
286
internal/serialization/reader_bench_test.go
Normal file
286
internal/serialization/reader_bench_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupEnv sets up environment variables for benchmarks
|
||||
func setupEnv(b *testing.B) {
|
||||
b.Helper()
|
||||
os.Setenv("BENCH_VAR", "benchmark_value")
|
||||
os.Setenv("BENCH_VAR_2", "second_value")
|
||||
os.Setenv("BENCH_VAR_3", "third_value")
|
||||
}
|
||||
|
||||
// cleanupEnv cleans up environment variables after benchmarks
|
||||
func cleanupEnv(b *testing.B) {
|
||||
b.Helper()
|
||||
os.Unsetenv("BENCH_VAR")
|
||||
os.Unsetenv("BENCH_VAR_2")
|
||||
os.Unsetenv("BENCH_VAR_3")
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_NoSubstitution benchmarks reading without any env substitutions
|
||||
func BenchmarkSubstituteEnvReader_NoSubstitution(b *testing.B) {
|
||||
r := strings.NewReader(`key: value
|
||||
name: test
|
||||
data: some content here
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_SingleSubstitution benchmarks reading with a single env substitution
|
||||
func BenchmarkSubstituteEnvReader_SingleSubstitution(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
r := strings.NewReader(`key: ${BENCH_VAR}
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_MultipleSubstitutions benchmarks reading with multiple env substitutions
|
||||
func BenchmarkSubstituteEnvReader_MultipleSubstitutions(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
r := strings.NewReader(`key1: ${BENCH_VAR}
|
||||
key2: ${BENCH_VAR_2}
|
||||
key3: ${BENCH_VAR_3}
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_LargeInput_NoSubstitution benchmarks large input without substitutions
|
||||
func BenchmarkSubstituteEnvReader_LargeInput_NoSubstitution(b *testing.B) {
|
||||
r := strings.NewReader(strings.Repeat("x", 100000))
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_LargeInput_WithSubstitutions benchmarks large input with scattered substitutions
|
||||
func BenchmarkSubstituteEnvReader_LargeInput_WithSubstitutions(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
var builder bytes.Buffer
|
||||
for range 100 {
|
||||
builder.WriteString(strings.Repeat("x", 1000))
|
||||
builder.WriteString("${BENCH_VAR}")
|
||||
}
|
||||
r := bytes.NewReader(builder.Bytes())
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_SmallBuffer benchmarks reading with a small buffer size
|
||||
func BenchmarkSubstituteEnvReader_SmallBuffer(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
r := strings.NewReader(`key: ${BENCH_VAR} and some more content here`)
|
||||
buf := make([]byte, 16)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
for {
|
||||
_, err := reader.Read(buf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_YAMLConfig benchmarks a realistic YAML config scenario
|
||||
func BenchmarkSubstituteEnvReader_YAMLConfig(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
r := strings.NewReader(`database:
|
||||
host: ${BENCH_VAR}
|
||||
port: ${BENCH_VAR_2}
|
||||
username: ${BENCH_VAR_3}
|
||||
password: ${BENCH_VAR}
|
||||
cache:
|
||||
enabled: true
|
||||
ttl: ${BENCH_VAR_2}
|
||||
server:
|
||||
host: ${BENCH_VAR}
|
||||
port: 8080
|
||||
`)
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_BoundaryPattern benchmarks patterns at buffer boundaries (4096 bytes)
|
||||
func BenchmarkSubstituteEnvReader_BoundaryPattern(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
// Pattern exactly at 4090 bytes, with ${VAR} crossing the 4096 boundary
|
||||
prefix := strings.Repeat("x", 4090)
|
||||
r := strings.NewReader(prefix + "${BENCH_VAR}")
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_MultipleBoundaries benchmarks multiple patterns crossing boundaries
|
||||
func BenchmarkSubstituteEnvReader_MultipleBoundaries(b *testing.B) {
|
||||
setupEnv(b)
|
||||
defer cleanupEnv(b)
|
||||
|
||||
var builder bytes.Buffer
|
||||
for range 10 {
|
||||
builder.WriteString(strings.Repeat("x", 4000))
|
||||
builder.WriteString("${BENCH_VAR}")
|
||||
}
|
||||
r := bytes.NewReader(builder.Bytes())
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_SpecialChars benchmarks substitution with special characters
|
||||
func BenchmarkSubstituteEnvReader_SpecialChars(b *testing.B) {
|
||||
os.Setenv("SPECIAL_BENCH_VAR", `value with "quotes" and \backslash\`)
|
||||
defer os.Unsetenv("SPECIAL_BENCH_VAR")
|
||||
|
||||
r := strings.NewReader(`key: ${SPECIAL_BENCH_VAR}
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_EmptyValue benchmarks substitution with empty value
|
||||
func BenchmarkSubstituteEnvReader_EmptyValue(b *testing.B) {
|
||||
os.Setenv("EMPTY_BENCH_VAR", "")
|
||||
defer os.Unsetenv("EMPTY_BENCH_VAR")
|
||||
|
||||
r := strings.NewReader(`key: ${EMPTY_BENCH_VAR}
|
||||
`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSubstituteEnvReader_DollarWithoutBrace benchmarks $ without following {
|
||||
func BenchmarkSubstituteEnvReader_DollarWithoutBrace(b *testing.B) {
|
||||
os.Setenv("BENCH_VAR", "benchmark_value")
|
||||
defer os.Unsetenv("BENCH_VAR")
|
||||
|
||||
r := strings.NewReader(`price: $100 and $200 for ${BENCH_VAR}`)
|
||||
|
||||
for b.Loop() {
|
||||
reader := NewSubstituteEnvReader(r)
|
||||
_, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFindIncompletePatternStart benchmarks the findIncompletePatternStart function
|
||||
func BenchmarkFindIncompletePatternStart(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"no pattern", strings.Repeat("hello world ", 100)},
|
||||
{"complete pattern", strings.Repeat("hello ${VAR} world ", 50)},
|
||||
{"dollar at end", strings.Repeat("hello ", 100) + "$"},
|
||||
{"incomplete at end", strings.Repeat("hello ", 100) + "${VAR"},
|
||||
{"large input no pattern", strings.Repeat("x", 5000)},
|
||||
{"large input with pattern", strings.Repeat("x", 4000) + "${VAR}"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
data := []byte(tc.input)
|
||||
for b.Loop() {
|
||||
findIncompletePatternStart(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
217
internal/serialization/reader_test.go
Normal file
217
internal/serialization/reader_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSubstituteEnvReader_Basic(t *testing.T) {
|
||||
os.Setenv("TEST_VAR", "hello")
|
||||
defer os.Unsetenv("TEST_VAR")
|
||||
|
||||
input := []byte(`key: ${TEST_VAR}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: "hello"`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_Multiple(t *testing.T) {
|
||||
os.Setenv("VAR1", "first")
|
||||
os.Setenv("VAR2", "second")
|
||||
defer os.Unsetenv("VAR1")
|
||||
defer os.Unsetenv("VAR2")
|
||||
|
||||
input := []byte(`a: ${VAR1}, b: ${VAR2}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `a: "first", b: "second"`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_NoSubstitution(t *testing.T) {
|
||||
input := []byte(`key: value`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: value`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_UnsetEnvError(t *testing.T) {
|
||||
os.Unsetenv("UNSET_VAR_FOR_TEST")
|
||||
|
||||
input := []byte(`key: ${UNSET_VAR_FOR_TEST}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
_, err := io.ReadAll(reader)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "UNSET_VAR_FOR_TEST is not set")
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_SmallBuffer(t *testing.T) {
|
||||
os.Setenv("SMALL_BUF_VAR", "value")
|
||||
defer os.Unsetenv("SMALL_BUF_VAR")
|
||||
|
||||
input := []byte(`key: ${SMALL_BUF_VAR}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
var result []byte
|
||||
buf := make([]byte, 3)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
result = append(result, buf[:n]...)
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, `key: "value"`, string(result))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_SpecialChars(t *testing.T) {
|
||||
os.Setenv("SPECIAL_VAR", `hello "world" \n`)
|
||||
defer os.Unsetenv("SPECIAL_VAR")
|
||||
|
||||
input := []byte(`key: ${SPECIAL_VAR}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: "hello \"world\" \\n"`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_EmptyValue(t *testing.T) {
|
||||
os.Setenv("EMPTY_VAR", "")
|
||||
defer os.Unsetenv("EMPTY_VAR")
|
||||
|
||||
input := []byte(`key: ${EMPTY_VAR}`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: ""`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_LargeInput(t *testing.T) {
|
||||
os.Setenv("LARGE_VAR", "replaced")
|
||||
defer os.Unsetenv("LARGE_VAR")
|
||||
|
||||
prefix := strings.Repeat("x", 5000)
|
||||
suffix := strings.Repeat("y", 5000)
|
||||
input := []byte(prefix + "${LARGE_VAR}" + suffix)
|
||||
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
expected := prefix + `"replaced"` + suffix
|
||||
require.Equal(t, expected, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_PatternAtBoundary(t *testing.T) {
|
||||
os.Setenv("BOUNDARY_VAR", "boundary_value")
|
||||
defer os.Unsetenv("BOUNDARY_VAR")
|
||||
|
||||
prefix := strings.Repeat("a", 4090)
|
||||
input := []byte(prefix + "${BOUNDARY_VAR}")
|
||||
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
expected := prefix + `"boundary_value"`
|
||||
require.Equal(t, expected, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_MultiplePatternsBoundary(t *testing.T) {
|
||||
os.Setenv("VAR_A", "aaa")
|
||||
os.Setenv("VAR_B", "bbb")
|
||||
defer os.Unsetenv("VAR_A")
|
||||
defer os.Unsetenv("VAR_B")
|
||||
|
||||
prefix := strings.Repeat("x", 4090)
|
||||
input := []byte(prefix + "${VAR_A} middle ${VAR_B}")
|
||||
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
expected := prefix + `"aaa" middle "bbb"`
|
||||
require.Equal(t, expected, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_YAMLConfig(t *testing.T) {
|
||||
os.Setenv("DB_HOST", "localhost")
|
||||
os.Setenv("DB_PORT", "5432")
|
||||
os.Setenv("DB_PASSWORD", "secret123")
|
||||
defer os.Unsetenv("DB_HOST")
|
||||
defer os.Unsetenv("DB_PORT")
|
||||
defer os.Unsetenv("DB_PASSWORD")
|
||||
|
||||
input := []byte(`database:
|
||||
host: ${DB_HOST}
|
||||
port: ${DB_PORT}
|
||||
password: ${DB_PASSWORD}
|
||||
`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
expected := `database:
|
||||
host: "localhost"
|
||||
port: "5432"
|
||||
password: "secret123"
|
||||
`
|
||||
require.Equal(t, expected, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_DollarWithoutBrace(t *testing.T) {
|
||||
input := []byte(`key: $NOT_A_PATTERN`)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `key: $NOT_A_PATTERN`, string(output))
|
||||
}
|
||||
|
||||
func TestSubstituteEnvReader_EmptyInput(t *testing.T) {
|
||||
input := []byte(``)
|
||||
reader := NewSubstituteEnvReader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ``, string(output))
|
||||
}
|
||||
|
||||
func TestFindIncompletePatternStart(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected int
|
||||
}{
|
||||
{"no pattern", "hello world", -1},
|
||||
{"complete pattern", "hello ${VAR} world", -1},
|
||||
{"dollar at end", "hello $", 6},
|
||||
{"dollar brace at end", "hello ${", 6},
|
||||
{"incomplete var at end", "hello ${VAR", 6},
|
||||
{"complete then incomplete", "hello ${VAR} ${INCOMPLETE", 13},
|
||||
{"multiple complete", "${A} ${B} ${C}", -1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := findIncompletePatternStart([]byte(tt.input))
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package serialization
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
@@ -85,6 +86,10 @@ func initPtr(dst reflect.Value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate performs struct validation using go-playground/validator tags.
|
||||
//
|
||||
// It collects all validation errors and returns them as a single error.
|
||||
// Field names in errors are prefixed with their namespace (e.g., "User.Email").
|
||||
func ValidateWithFieldTags(s any) gperr.Error {
|
||||
var errs gperr.Builder
|
||||
err := validate.Struct(s)
|
||||
@@ -257,10 +262,11 @@ func initTypeKeyFieldIndexesMap(t reflect.Type) typeInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// MapUnmarshalValidate takes a SerializedObject and a target value, and assigns the values in the SerializedObject to the target value.
|
||||
// MapUnmarshalValidate ignores case differences between the field names in the SerializedObject and the target.
|
||||
// MapUnmarshalValidate takes a SerializedObject and a target value,
|
||||
// and assigns the values in the SerializedObject to the target value.
|
||||
//
|
||||
// It ignores case differences between the field names in the SerializedObject and the target.
|
||||
//
|
||||
// The target value must be a struct or a map[string]any.
|
||||
// If the target value is a struct , and implements the MapUnmarshaller interface,
|
||||
// the UnmarshalMap method will be called.
|
||||
//
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -29,17 +29,19 @@ type CustomValidator interface {
|
||||
var validatorType = reflect.TypeFor[CustomValidator]()
|
||||
|
||||
func ValidateWithCustomValidator(v reflect.Value) gperr.Error {
|
||||
vt := v.Type()
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
// return nil
|
||||
return validateWithValidator(reflect.New(v.Type().Elem()))
|
||||
}
|
||||
if v.Type().Implements(validatorType) {
|
||||
elemType := vt.Elem()
|
||||
if vt.Implements(validatorType) {
|
||||
if v.IsNil() {
|
||||
return reflect.New(elemType).Interface().(CustomValidator).Validate()
|
||||
}
|
||||
return v.Interface().(CustomValidator).Validate()
|
||||
}
|
||||
return validateWithValidator(v.Elem())
|
||||
if elemType.Implements(validatorType) {
|
||||
return v.Elem().Interface().(CustomValidator).Validate()
|
||||
}
|
||||
} else {
|
||||
vt := v.Type()
|
||||
if vt.PkgPath() != "" { // not a builtin type
|
||||
// prioritize pointer method
|
||||
if v.CanAddr() {
|
||||
@@ -56,10 +58,3 @@ func ValidateWithCustomValidator(v reflect.Value) gperr.Error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWithValidator(v reflect.Value) gperr.Error {
|
||||
if v.Type().Implements(validatorType) {
|
||||
return v.Interface().(CustomValidator).Validate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package types
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
)
|
||||
@@ -10,14 +11,14 @@ import (
|
||||
func TestDockerProviderConfigUnmarshalMap(t *testing.T) {
|
||||
t.Run("string", func(t *testing.T) {
|
||||
var cfg map[string]*DockerProviderConfig
|
||||
err := serialization.UnmarshalValidateYAML([]byte("test: http://localhost:2375"), &cfg)
|
||||
err := serialization.UnmarshalValidate([]byte("test: http://localhost:2375"), &cfg, yaml.Unmarshal)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375"}, cfg["test"])
|
||||
})
|
||||
|
||||
t.Run("detailed", func(t *testing.T) {
|
||||
var cfg map[string]*DockerProviderConfig
|
||||
err := serialization.UnmarshalValidateYAML([]byte(`
|
||||
err := serialization.UnmarshalValidate([]byte(`
|
||||
test:
|
||||
scheme: http
|
||||
host: localhost
|
||||
@@ -25,7 +26,7 @@ test:
|
||||
tls:
|
||||
ca_file: /etc/ssl/ca.crt
|
||||
cert_file: /etc/ssl/cert.crt
|
||||
key_file: /etc/ssl/key.crt`), &cfg)
|
||||
key_file: /etc/ssl/key.crt`), &cfg, yaml.Unmarshal)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, &DockerProviderConfig{URL: "http://localhost:2375", TLS: &DockerTLSConfig{CAFile: "/etc/ssl/ca.crt", CertFile: "/etc/ssl/cert.crt", KeyFile: "/etc/ssl/key.crt"}}, cfg["test"])
|
||||
})
|
||||
@@ -131,7 +132,7 @@ func TestDockerProviderConfigValidation(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var cfg map[string]*DockerProviderConfig
|
||||
err := serialization.UnmarshalValidateYAML([]byte(test.yamlStr), &cfg)
|
||||
err := serialization.UnmarshalValidate([]byte(test.yamlStr), &cfg, yaml.Unmarshal)
|
||||
if test.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
|
||||
@@ -41,11 +41,11 @@ type (
|
||||
DockerCfg DockerProviderConfig `json:"docker_cfg" validate:"required"`
|
||||
ContainerID string `json:"container_id" validate:"required"`
|
||||
ContainerName string `json:"container_name" validate:"required"`
|
||||
} // @name DockerConfig
|
||||
} // @name IdlewatcherDockerConfig
|
||||
ProxmoxConfig struct {
|
||||
Node string `json:"node" validate:"required"`
|
||||
VMID int `json:"vmid" validate:"required"`
|
||||
} // @name ProxmoxNodeConfig
|
||||
} // @name IdlewatcherProxmoxNodeConfig
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -49,5 +49,7 @@ COPY --from=builder /app/run /app/run
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
LABEL proxy.#1.healthcheck.disable=true
|
||||
|
||||
ENV LISTEN_ADDR=0.0.0.0:2375
|
||||
CMD ["/app/run"]
|
||||
Reference in New Issue
Block a user