Compare commits

...

9 Commits
dev ... v0.25.3

Author SHA1 Message Date
yusing
fb96a2a4f1 fix(Makefile): exclude specific directories from gomod_paths search 2026-01-31 23:49:47 +08:00
yusing
fdfb682e2a fix(api): prevent timeout during agent verification
Send early HTTP 100 Continue response before processing to avoid
timeouts, and propagate request context through the verification flow
for proper cancellation handling.
2026-01-31 19:11:48 +08:00
yusing
8d56c61826 fix(autocert): rebuild SNI matcher after ObtainCertAll operations
The ObtainCertAll method was missing a call to rebuildSNIMatcher(),
which could leave the SNI configuration stale after certificate
renewals. Both ObtainCertIfNotExistsAll and ObtainCertAll now
consistently rebuild the SNI matcher after their operations.

This was introduced in 3ad6e98a17,
not a bug fix for previous version
2026-01-31 18:57:15 +08:00
yusing
d1fca7e987 feat(route): add YAML anchor exclusion reason
Add ExcludedReasonYAMLAnchor to explicitly identify routes with "x-" prefix
used for YAML anchors and references. These routes are removed before
validation.
2026-01-31 18:56:16 +08:00
yusing
95f88a6f3c fix(route): allow excluded routes to use localhost addresses
Routes marked for exclusion should bypass normal validation checks,
including the restriction on localhost/127.0.0.1 hostnames.
2026-01-31 18:51:15 +08:00
yusing
c0e2cf63b5 fix(health/check): validate URL port before dialing in Stream check
Add port validation to return an unhealthy result with descriptive
message when URL has no port specified, preventing potential dialing
errors on zero port.
2026-01-31 18:50:13 +08:00
yusing
6388d07f64 chore: disable godoxy health checking for socket-proxy 2026-01-31 17:09:00 +08:00
yusing
15e50322c9 feat(autocert): generate unique ACME key paths per CA directory URL
Previously, ACME keys were stored at a single default path regardless of
which CA directory URL was configured. This caused key conflicts when
using multiple different ACME CAs.

Now, the key path is derived from a SHA256 hash of the CA directory URL,
allowing each CA to have its own key file:
- Default CA (Let's Encrypt): certs/acme.key
- Custom CA: certs/acme_<url_hash_16chars>.key

This enables running certificates against multiple ACME providers without
key collision issues.
2026-01-31 16:49:44 +08:00
yusing
3ad6e98a17 fix(autocert): correct ObtainCert error handling
- ObtainCertIfNotExistsAll longer fail on fs.ErrNotExists
- Separate public LoadCertAll (loads all providers) from private loadCert
- LoadCertAll now uses allProviders() for iteration
- Updated tests to use LoadCertAll
2026-01-31 16:49:37 +08:00
9 changed files with 81 additions and 36 deletions

View File

@@ -92,7 +92,7 @@ docker-build-test:
go_ver := $(shell go version | cut -d' ' -f3 | cut -d'o' -f2) 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) 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: update-go:
for file in ${files}; do \ for file in ${files}; do \

View File

@@ -1,6 +1,7 @@
package agentapi package agentapi
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -36,6 +37,9 @@ type VerifyNewAgentRequest struct {
// @Failure 500 {object} ErrorResponse // @Failure 500 {object} ErrorResponse
// @Router /agent/verify [post] // @Router /agent/verify [post]
func Verify(c *gin.Context) { func Verify(c *gin.Context) {
// avoid timeout waiting for response headers
c.Status(http.StatusContinue)
var request VerifyNewAgentRequest var request VerifyNewAgentRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
@@ -60,7 +64,7 @@ func Verify(c *gin.Context) {
return 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 { if err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
return return
@@ -82,7 +86,7 @@ func Verify(c *gin.Context) {
var errAgentAlreadyExists = gperr.New("agent already exists") 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 var agentCfg agent.AgentConfig
agentCfg.Addr = host agentCfg.Addr = host
agentCfg.Runtime = containerRuntime agentCfg.Runtime = containerRuntime
@@ -99,7 +103,7 @@ func verifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair, contain
return 0, errAgentAlreadyExists 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 { if err != nil {
return 0, gperr.Wrap(err, "failed to initialize agent config") return 0, gperr.Wrap(err, "failed to initialize agent config")
} }

View File

@@ -4,10 +4,13 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"path/filepath"
"regexp" "regexp"
"github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certcrypto"
@@ -27,7 +30,7 @@ type Config struct {
CertPath string `json:"cert_path,omitempty"` CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"` KeyPath string `json:"key_path,omitempty"`
Extra []ConfigExtra `json:"extra,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"` Provider string `json:"provider,omitempty"`
Options map[string]strutils.Redacted `json:"options,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 cfg.KeyPath = KeyFileDefault
} }
if cfg.ACMEKeyPath == "" { if cfg.ACMEKeyPath == "" {
cfg.ACMEKeyPath = ACMEKeyFileDefault cfg.ACMEKeyPath = acmeKeyPath(cfg.CADirURL)
} }
b := gperr.NewBuilder("certificate error") b := gperr.NewBuilder("certificate error")
@@ -272,3 +275,16 @@ func (cfg *Config) SaveACMEKey(key *ecdsa.PrivateKey) error {
} }
return os.WriteFile(cfg.ACMEKeyPath, data, 0o600) 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")
}

View File

@@ -1,8 +1,7 @@
package autocert package autocert
const ( const (
certBasePath = "certs/" certBasePath = "certs/"
CertFileDefault = certBasePath + "cert.crt" CertFileDefault = certBasePath + "cert.crt"
KeyFileDefault = certBasePath + "priv.key" KeyFileDefault = certBasePath + "priv.key"
ACMEKeyFileDefault = certBasePath + "acme.key"
) )

View File

@@ -222,13 +222,14 @@ func (p *Provider) ObtainCertIfNotExistsAll() error {
}) })
} }
err := errs.Wait().Error()
p.rebuildSNIMatcher() p.rebuildSNIMatcher()
return errs.Wait().Error() return err
} }
// obtainCertIfNotExists obtains a new certificate for this provider if it does not exist. // obtainCertIfNotExists obtains a new certificate for this provider if it does not exist.
func (p *Provider) obtainCertIfNotExists() error { func (p *Provider) obtainCertIfNotExists() error {
err := p.LoadCert() err := p.loadCert()
if err == nil { if err == nil {
return nil return nil
} }
@@ -261,7 +262,10 @@ func (p *Provider) ObtainCertAll() error {
return nil 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. // ObtainCert renews existing certificate or obtains a new certificate for this provider.
@@ -346,29 +350,32 @@ func (p *Provider) ObtainCert() error {
return nil return nil
} }
func (p *Provider) LoadCert() error { func (p *Provider) LoadCertAll() error {
var errs gperr.Builder 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) cert, err := tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath)
if err != nil { if err != nil {
errs.Addf("load SSL certificate: %w", p.fmtError(err)) return err
} }
expiries, err := getCertExpiries(&cert) expiries, err := getCertExpiries(&cert)
if err != nil { if err != nil {
errs.Addf("parse SSL certificate: %w", p.fmtError(err)) return err
} }
p.tlsCert = &cert p.tlsCert = &cert
p.certExpiries = expiries p.certExpiries = expiries
for _, ep := range p.extraProviders { return nil
if err := ep.LoadCert(); err != nil {
errs.Add(err)
}
}
p.rebuildSNIMatcher()
return errs.Error()
} }
// PrintCertExpiriesAll prints the certificate expiries for this provider and all extra providers. // PrintCertExpiriesAll prints the certificate expiries for this provider and all extra providers.

View File

@@ -81,7 +81,7 @@ func TestGetCertBySNI(t *testing.T) {
p, err := autocert.NewProvider(cfg, nil, nil) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "a.internal.example.com"}) 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) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.example.com"}) 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) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "unknown.domain.com"}) 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) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert, err := p.GetCert(nil) cert, err := p.GetCert(nil)
@@ -197,7 +197,7 @@ func TestGetCertBySNI(t *testing.T) {
p, err := autocert.NewProvider(cfg, nil, nil) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: ""}) cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: ""})
@@ -229,7 +229,7 @@ func TestGetCertBySNI(t *testing.T) {
p, err := autocert.NewProvider(cfg, nil, nil) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "FOO.EXAMPLE.COM"}) 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) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: " foo.example.com. "}) 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) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.a.example.com"}) 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) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "bar.example.com"}) 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) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert1, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.test.com"}) 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) p, err := autocert.NewProvider(cfg, nil, nil)
require.NoError(t, err) require.NoError(t, err)
err = p.LoadCert() err = p.LoadCertAll()
require.NoError(t, err) require.NoError(t, err)
cert1, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.example.com"}) cert1, err := p.GetCert(&tls.ClientHelloInfo{ServerName: "foo.example.com"})

View File

@@ -12,6 +12,14 @@ import (
) )
func Stream(ctx context.Context, url *url.URL, timeout time.Duration) (types.HealthCheckResult, error) { 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{ dialer := net.Dialer{
Timeout: timeout, Timeout: timeout,
FallbackDelay: -1, FallbackDelay: -1,

View File

@@ -254,7 +254,7 @@ func (r *Route) validate() gperr.Error {
} }
// return error if route is localhost:<godoxy_port> but route is not agent // return error if route is localhost:<godoxy_port> but route is not agent
if !r.IsAgent() { if !r.IsAgent() && !r.ShouldExclude() {
switch r.Host { switch r.Host {
case "localhost", "127.0.0.1": case "localhost", "127.0.0.1":
switch r.Port.Proxy { switch r.Port.Proxy {
@@ -749,6 +749,7 @@ const (
ExcludedReasonNoPortSpecified ExcludedReasonNoPortSpecified
ExcludedReasonBlacklisted ExcludedReasonBlacklisted
ExcludedReasonBuildx ExcludedReasonBuildx
ExcludedReasonYAMLAnchor
ExcludedReasonOld ExcludedReasonOld
) )
@@ -768,6 +769,8 @@ func (re ExcludedReason) String() string {
return "Blacklisted (backend service or database)" return "Blacklisted (backend service or database)"
case ExcludedReasonBuildx: case ExcludedReasonBuildx:
return "Buildx" return "Buildx"
case ExcludedReasonYAMLAnchor:
return "YAML anchor or reference"
case ExcludedReasonOld: case ExcludedReasonOld:
return "Container renaming intermediate state" return "Container renaming intermediate state"
default: default:
@@ -802,6 +805,12 @@ func (r *Route) findExcludedReason() ExcludedReason {
} else if r.IsZeroPort() && r.Scheme != route.SchemeFileServer { } else if r.IsZeroPort() && r.Scheme != route.SchemeFileServer {
return ExcludedReasonNoPortSpecified 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") { if strings.HasSuffix(r.Alias, "-old") {
return ExcludedReasonOld return ExcludedReasonOld
} }

View File

@@ -49,5 +49,7 @@ COPY --from=builder /app/run /app/run
WORKDIR /app WORKDIR /app
LABEL proxy.#1.healthcheck.disable=true
ENV LISTEN_ADDR=0.0.0.0:2375 ENV LISTEN_ADDR=0.0.0.0:2375
CMD ["/app/run"] CMD ["/app/run"]