Compare commits

..

8 Commits

12 changed files with 299 additions and 70 deletions

View File

@@ -33,6 +33,7 @@ Have questions? Ask [ChatGPT](https://chatgpt.com/g/g-6825390374b481919ad482f2e4
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [How does GoDoxy work](#how-does-godoxy-work)
- [Update / Uninstall system agent](#update--uninstall-system-agent)
- [Screenshots](#screenshots)
- [idlesleeper](#idlesleeper)
- [Metrics and Logs](#metrics-and-logs)
@@ -128,6 +129,20 @@ Configure Wildcard DNS Record(s) to point to machine running `GoDoxy`, e.g.
>
> For example, with the label `proxy.aliases: qbt` you can access your app via `qbt.domain.com`.
## Update / Uninstall system agent
Update:
```bash
bash -c "$(curl -fsSL https://github.com/yusing/godoxy/raw/refs/heads/main/scripts/install-agent.sh)" -- update
```
Uninstall:
```bash
bash -c "$(curl -fsSL https://github.com/yusing/godoxy/raw/refs/heads/main/scripts/install-agent.sh)" -- uninstall
```
## Screenshots
### idlesleeper

View File

@@ -34,6 +34,7 @@
- [安裝](#安裝)
- [手動安裝](#手動安裝)
- [資料夾結構](#資料夾結構)
- [更新 / 卸載系統代理 (System Agent)](#更新--卸載系統代理-system-agent)
- [截圖](#截圖)
- [閒置休眠](#閒置休眠)
- [監控](#監控)
@@ -144,6 +145,20 @@
└── .env
```
## 更新 / 卸載系統代理 (System Agent)
更新:
```bash
bash -c "$(curl -fsSL https://github.com/yusing/godoxy/raw/refs/heads/main/scripts/install-agent.sh)" -- update
```
卸載:
```bash
bash -c "$(curl -fsSL https://github.com/yusing/godoxy/raw/refs/heads/main/scripts/install-agent.sh)" -- uninstall
```
## 截圖
### 閒置休眠

View File

@@ -22,7 +22,7 @@ import (
type AgentConfig struct {
Addr string `json:"addr"`
Name string `json:"name"`
Version string `json:"version"`
Version pkg.Version `json:"version"`
Runtime ContainerRuntime `json:"runtime"`
httpClient *http.Client
@@ -148,11 +148,10 @@ func (cfg *AgentConfig) StartWithCerts(ctx context.Context, ca, crt, key []byte)
return fmt.Errorf("failed to get agent runtime: HTTP %d %s", status, runtimeBytes)
}
cfg.Version = string(agentVersionBytes)
agentVersion := pkg.ParseVersion(cfg.Version)
cfg.Version = pkg.ParseVersion(string(agentVersionBytes))
if serverVersion.IsNewerMajorThan(agentVersion) {
log.Warn().Msgf("agent %s major version mismatch: server: %s, agent: %s", cfg.Name, serverVersion, agentVersion)
if serverVersion.IsNewerThanMajor(cfg.Version) {
log.Warn().Msgf("agent %s major version mismatch: server: %s, agent: %s", cfg.Name, serverVersion, cfg.Version)
}
log.Info().Msgf("agent %q initialized", cfg.Name)

View File

@@ -0,0 +1,76 @@
package agentproxy
import (
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"time"
route "github.com/yusing/go-proxy/internal/route/types"
)
type Config struct {
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"` // host or host:port
route.HTTPConfig
}
func ConfigFromHeaders(h http.Header) (Config, error) {
cfg, err := proxyConfigFromHeaders(h)
if err != nil {
return cfg, err
}
if cfg.Host == "" {
cfg = proxyConfigFromHeadersLegacy(h)
}
return cfg, nil
}
func proxyConfigFromHeadersLegacy(h http.Header) (cfg Config) {
cfg.Host = h.Get(HeaderXProxyHost)
isHTTPS, _ := strconv.ParseBool(h.Get(HeaderXProxyHTTPS))
cfg.NoTLSVerify, _ = strconv.ParseBool(h.Get(HeaderXProxySkipTLSVerify))
responseHeaderTimeout, err := strconv.Atoi(h.Get(HeaderXProxyResponseHeaderTimeout))
if err != nil {
responseHeaderTimeout = 0
}
cfg.ResponseHeaderTimeout = time.Duration(responseHeaderTimeout) * time.Second
cfg.Scheme = "http"
if isHTTPS {
cfg.Scheme = "https"
}
return
}
func proxyConfigFromHeaders(h http.Header) (cfg Config, err error) {
cfg.Scheme = h.Get(HeaderXProxyScheme)
cfg.Host = h.Get(HeaderXProxyHost)
cfgBase64 := h.Get(HeaderXProxyConfig)
cfgJSON, err := base64.StdEncoding.DecodeString(cfgBase64)
if err != nil {
return cfg, err
}
err = json.Unmarshal(cfgJSON, &cfg)
return cfg, err
}
func (cfg *Config) SetAgentProxyConfigHeadersLegacy(h http.Header) {
h.Set(HeaderXProxyHost, cfg.Host)
h.Set(HeaderXProxyHTTPS, strconv.FormatBool(cfg.Scheme == "https"))
h.Set(HeaderXProxySkipTLSVerify, strconv.FormatBool(cfg.NoTLSVerify))
h.Set(HeaderXProxyResponseHeaderTimeout, strconv.Itoa(int(cfg.ResponseHeaderTimeout.Round(time.Second).Seconds())))
}
func (cfg *Config) SetAgentProxyConfigHeaders(h http.Header) {
h.Set(HeaderXProxyHost, cfg.Host)
h.Set(HeaderXProxyScheme, string(cfg.Scheme))
cfgJSON, _ := json.Marshal(cfg.HTTPConfig)
cfgBase64 := base64.StdEncoding.EncodeToString(cfgJSON)
h.Set(HeaderXProxyConfig, cfgBase64)
}

View File

@@ -1,27 +1,14 @@
package agentproxy
import (
"net/http"
"strconv"
const (
HeaderXProxyScheme = "X-Proxy-Scheme"
HeaderXProxyHost = "X-Proxy-Host"
HeaderXProxyConfig = "X-Proxy-Config"
)
// deprecated
const (
HeaderXProxyHost = "X-Proxy-Host"
HeaderXProxyHTTPS = "X-Proxy-Https"
HeaderXProxySkipTLSVerify = "X-Proxy-Skip-Tls-Verify"
HeaderXProxyResponseHeaderTimeout = "X-Proxy-Response-Header-Timeout"
)
type AgentProxyHeaders struct {
Host string
IsHTTPS bool
SkipTLSVerify bool
ResponseHeaderTimeout int
}
func SetAgentProxyHeaders(r *http.Request, headers *AgentProxyHeaders) {
r.Header.Set(HeaderXProxyHost, headers.Host)
r.Header.Set(HeaderXProxyHTTPS, strconv.FormatBool(headers.IsHTTPS))
r.Header.Set(HeaderXProxySkipTLSVerify, strconv.FormatBool(headers.SkipTLSVerify))
r.Header.Set(HeaderXProxyResponseHeaderTimeout, strconv.Itoa(headers.ResponseHeaderTimeout))
}

View File

@@ -1,10 +1,9 @@
package handler
import (
"crypto/tls"
"fmt"
"net/http"
"net/http/httputil"
"strconv"
"time"
"github.com/yusing/go-proxy/agent/pkg/agent"
@@ -24,31 +23,24 @@ func NewTransport() *http.Transport {
}
func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
host := r.Header.Get(agentproxy.HeaderXProxyHost)
isHTTPS, _ := strconv.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS))
skipTLSVerify, _ := strconv.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify))
responseHeaderTimeout, err := strconv.Atoi(r.Header.Get(agentproxy.HeaderXProxyResponseHeaderTimeout))
cfg, err := agentproxy.ConfigFromHeaders(r.Header)
if err != nil {
responseHeaderTimeout = 0
}
if host == "" {
http.Error(w, "missing required headers", http.StatusBadRequest)
http.Error(w, fmt.Sprintf("failed to parse agent proxy config: %s", err.Error()), http.StatusBadRequest)
return
}
scheme := "http"
if isHTTPS {
scheme = "https"
}
transport := NewTransport()
if skipTLSVerify {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
if cfg.ResponseHeaderTimeout > 0 {
transport.ResponseHeaderTimeout = cfg.ResponseHeaderTimeout
}
if cfg.DisableCompression {
transport.DisableCompression = true
}
if responseHeaderTimeout > 0 {
transport.ResponseHeaderTimeout = time.Duration(responseHeaderTimeout) * time.Second
transport.TLSClientConfig, err = cfg.BuildTLSConfig(r.URL)
if err != nil {
http.Error(w, fmt.Sprintf("failed to build TLS client config: %s", err.Error()), http.StatusInternalServerError)
return
}
r.URL.Scheme = ""
@@ -58,8 +50,8 @@ func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
rp := &httputil.ReverseProxy{
Director: func(r *http.Request) {
r.URL.Scheme = scheme
r.URL.Host = host
r.URL.Scheme = cfg.Scheme
r.URL.Host = cfg.Host
},
Transport: transport,
}

View File

@@ -1,23 +1,17 @@
# Stage 1: deps
FROM golang:1.25.0-alpine AS deps
FROM alpine:3.22 AS deps
HEALTHCHECK NONE
# package version does not matter
# trunk-ignore(hadolint/DL3018)
RUN apk add --no-cache tzdata make libcap-setcap
RUN apk add --no-cache tzdata
# Stage 3: Final image
FROM alpine:3.22
# Stage 2: Final image
FROM deps
LABEL maintainer="yusing@6uo.me"
LABEL proxy.exclude=1
# copy timezone data
COPY --from=deps /usr/share/zoneinfo /usr/share/zoneinfo
# copy certs
COPY --from=deps /etc/ssl/certs /etc/ssl/certs
ARG TARGET
ENV TARGET=${TARGET}

View File

@@ -1,7 +1,6 @@
package route
import (
"crypto/tls"
"net/http"
"sync"
@@ -20,6 +19,7 @@ import (
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/types"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
"github.com/yusing/go-proxy/pkg"
)
type ReveseProxyRoute struct {
@@ -44,10 +44,12 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) {
trans = a.Transport()
proxyURL = nettypes.NewURL(agent.HTTPProxyURL)
} else {
trans = gphttp.NewTransport()
if httpConfig.NoTLSVerify {
trans.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
tlsConfig, err := httpConfig.BuildTLSConfig(&base.ProxyURL.URL)
if err != nil {
return nil, err
}
trans = gphttp.NewTransportWithTLSConfig(tlsConfig)
if httpConfig.ResponseHeaderTimeout > 0 {
trans.ResponseHeaderTimeout = httpConfig.ResponseHeaderTimeout
}
@@ -67,15 +69,19 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) {
}
if a != nil {
headers := &agentproxy.AgentProxyHeaders{
Host: base.ProxyURL.Host,
IsHTTPS: base.ProxyURL.Scheme == "https",
SkipTLSVerify: httpConfig.NoTLSVerify,
ResponseHeaderTimeout: int(httpConfig.ResponseHeaderTimeout.Seconds()),
cfg := agentproxy.Config{
Scheme: base.ProxyURL.Scheme,
Host: base.ProxyURL.Host,
HTTPConfig: httpConfig,
}
setHeaderFunc := cfg.SetAgentProxyConfigHeaders
if !a.Version.IsOlderThan(pkg.Ver(0, 18, 6)) {
setHeaderFunc = cfg.SetAgentProxyConfigHeadersLegacy
}
ori := rp.HandlerFunc
rp.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
agentproxy.SetAgentProxyHeaders(r, headers)
setHeaderFunc(r.Header)
ori(w, r)
}
}

View File

@@ -70,7 +70,14 @@ func (s *TCPTCPStream) LocalAddr() net.Addr {
}
func (s *TCPTCPStream) MarshalZerologObject(e *zerolog.Event) {
e.Str("protocol", "tcp-tcp").Str("listen", s.listener.Addr().String()).Str("dst", s.dst.String())
e.Str("protocol", "tcp-tcp")
if s.listener != nil {
e.Str("listen", s.listener.Addr().String())
}
if s.dst != nil {
e.Str("dst", s.dst.String())
}
}
func (s *TCPTCPStream) listen(ctx context.Context) {

View File

@@ -113,7 +113,13 @@ func (s *UDPUDPStream) LocalAddr() net.Addr {
}
func (s *UDPUDPStream) MarshalZerologObject(e *zerolog.Event) {
e.Str("protocol", "udp-udp").Str("name", s.name).Str("dst", s.dst.String())
e.Str("protocol", "udp-udp")
if s.name != "" {
e.Str("name", s.name)
}
if s.dst != nil {
e.Str("dst", s.dst.String())
}
}
func (s *UDPUDPStream) listen(ctx context.Context) {

View File

@@ -1,11 +1,118 @@
package route
import (
"crypto/tls"
"crypto/x509"
"net/url"
"os"
"strings"
"time"
"github.com/yusing/go-proxy/internal/gperr"
)
type HTTPConfig struct {
NoTLSVerify bool `json:"no_tls_verify,omitempty"`
ResponseHeaderTimeout time.Duration `json:"response_header_timeout,omitempty" swaggertype:"primitive,integer"`
DisableCompression bool `json:"disable_compression,omitempty"`
// SSL/TLS proxy options (nginx-like)
SSLServerName *string `json:"ssl_server_name,omitempty"` // SNI server name
SSLTrustedCertificate string `json:"ssl_trusted_certificate,omitempty"` // Path to trusted CA certificates
SSLCertificate string `json:"ssl_certificate,omitempty"` // Path to client certificate
SSLCertificateKey string `json:"ssl_certificate_key,omitempty"` // Path to client certificate key
SSLProtocols []string `json:"ssl_protocols,omitempty"` // Allowed TLS protocols
}
// BuildTLSConfig creates a TLS configuration based on the HTTP config options.
func (cfg *HTTPConfig) BuildTLSConfig(targetURL *url.URL) (*tls.Config, gperr.Error) {
tlsConfig := &tls.Config{}
// Handle InsecureSkipVerify (legacy NoTLSVerify option)
if cfg.NoTLSVerify {
tlsConfig.InsecureSkipVerify = true
}
// Handle ssl_server_name (SNI)
if cfg.SSLServerName != nil {
switch *cfg.SSLServerName {
case "off":
// Disable SNI by setting empty string
tlsConfig.ServerName = ""
case "on", "":
// Use hostname from target URL for SNI
tlsConfig.ServerName = targetURL.Hostname()
default:
tlsConfig.ServerName = *cfg.SSLServerName
}
} else {
// Default behavior - use hostname for SNI
tlsConfig.ServerName = targetURL.Hostname()
}
// Handle ssl_trusted_certificate
if cfg.SSLTrustedCertificate != "" {
caCertData, err := os.ReadFile(cfg.SSLTrustedCertificate)
if err != nil {
return nil, gperr.New("failed to read trusted certificate file").
Subject(cfg.SSLTrustedCertificate).
With(err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCertData) {
return nil, gperr.New("failed to parse trusted certificates").
Subject(cfg.SSLTrustedCertificate)
}
tlsConfig.RootCAs = caCertPool
}
// Handle ssl_certificate and ssl_certificate_key (client certificates)
if cfg.SSLCertificate != "" {
if cfg.SSLCertificateKey == "" {
return nil, gperr.New("ssl_certificate_key is required when ssl_certificate is specified")
}
clientCert, err := tls.LoadX509KeyPair(cfg.SSLCertificate, cfg.SSLCertificateKey)
if err != nil {
return nil, gperr.New("failed to load client certificate").
Subject(cfg.SSLCertificate).
With(err)
}
tlsConfig.Certificates = []tls.Certificate{clientCert}
}
// Handle ssl_protocols (TLS versions)
if len(cfg.SSLProtocols) > 0 {
var minVersion, maxVersion uint16
for _, protocol := range cfg.SSLProtocols {
var version uint16
switch strings.ToLower(protocol) {
case "tlsv1.0":
version = tls.VersionTLS10
case "tlsv1.1":
version = tls.VersionTLS11
case "tlsv1.2":
version = tls.VersionTLS12
case "tlsv1.3":
version = tls.VersionTLS13
default:
return nil, gperr.New("unsupported TLS protocol").
Subject(protocol)
}
if minVersion == 0 || version < minVersion {
minVersion = version
}
if maxVersion == 0 || version > maxVersion {
maxVersion = version
}
}
tlsConfig.MinVersion = minVersion
tlsConfig.MaxVersion = maxVersion
}
return tlsConfig, nil
}

View File

@@ -43,8 +43,8 @@ func init() {
type Version struct{ Generation, Major, Minor int }
func Ver(major, minor, patch int) Version {
return Version{major, minor, patch}
func Ver(gen, major, minor int) Version {
return Version{gen, major, minor}
}
func (v Version) String() string {
@@ -55,13 +55,38 @@ func (v Version) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}
func (v Version) IsNewerMajorThan(other Version) bool {
func (v Version) IsNewerThan(other Version) bool {
if v.Generation != other.Generation {
return v.Generation > other.Generation
}
if v.Major != other.Major {
return v.Major > other.Major
}
return v.Minor > other.Minor
}
func (v Version) IsNewerThanMajor(other Version) bool {
if v.Generation != other.Generation {
return v.Generation > other.Generation
}
return v.Major > other.Major
}
func (v Version) IsOlderThan(other Version) bool {
return !v.IsNewerThan(other)
}
func (v Version) IsOlderThanMajor(other Version) bool {
if v.Generation != other.Generation {
return v.Generation < other.Generation
}
return v.Major < other.Major
}
func (v Version) IsOlderMajorThan(other Version) bool {
return !v.IsNewerThanMajor(other)
}
func (v Version) IsEqual(other Version) bool {
return v.Generation == other.Generation && v.Major == other.Major && v.Minor == other.Minor
}