Compare commits

...

13 Commits

Author SHA1 Message Date
yusing
aaa3c9a8d8 fix(swagger): correct type names in swagger docs
Rename icon-related types in swagger docs:
- homepage.FetchResult → iconfetch.Result
- homepage.IconMetaSearch → iconlist.IconMetaSearch
- homepage.IconSource → icons.Source
- Shorten enum varnames (IconSourceAbsolute → SourceAbsolute, etc.)
- Add x-nullable: true to rules arrays
2026-01-10 15:57:56 +08:00
yusing
bc44de3196 feat(rules): add "on: default" rule syntax for default rule
- Add OnDefault rule type that matches when no other rules match
- Add validation to prevent multiple default rules
- Fix typo: extension → extensions in route config JSON tag
2026-01-10 15:53:26 +08:00
yusing
12b784d126 feat(serialization): add validation support for custom slice types
Enhanced the ConvertSlice function to include validation for destination slices that implement the CustomValidator interface. If validation fails, errors are collected and returned, ensuring data integrity during slice conversion.
2026-01-10 15:49:58 +08:00
yusing
71f6636cc3 refactor(serialization): optimize deserialization 2026-01-10 15:43:34 +08:00
yusing
cc1fe30045 refactor(scripts/wiki): rewrite markdown links when syncing impl docs to wiki
- Convert intra-repo README links to VitePress routes for SPA navigation
- Rewrite source file references (e.g., config.go:29) to GitHub blob links
- Makefile now passes REPO_URL to update-wiki for link rewriting
- Correct agent README.md file links from full to relative paths
- skip introduction.md when syncing
2026-01-10 13:54:22 +08:00
yusing
4ec352f1f6 refactor(homepage/icon): check service health before fetching icons and add retry logic
The icon fetching logic now checks if the target service is healthy before
attempting to fetch icons. If the health monitor reports an unhealthy status,
the function returns HTTP 503 Service Unavailable instead of proceeding.

Additionally, the icon cache lookup now includes infinite retry logic with a
15-second backoff interval, improving resilience during transient service
outages. Previously, failed lookups would not be retried.

The `route` interface was extended with a `HealthMonitor()` method to support
the health check functionality.
2026-01-09 21:48:35 +08:00
yusing
df530245bd fix(homepage/icon): set icons provider on init (introduced in 74f97a6621) 2026-01-09 21:41:32 +08:00
yusing
1a022bb3f4 refactor(route): improve References method to handle FQDN alias 2026-01-09 21:38:21 +08:00
yusing
2e57ca7743 fix(agent/stream) TCP/UDP server now handle stream headers with read deadlines 2026-01-09 21:22:51 +08:00
yusing
69d04f1b76 refactor(agent): remove goutils/server dependency and use direct HTTP server setup
- Replaced `goutils/server` helper library with manual HTTP server configuration for agent socketproxy
- Removed entire `agent/pkg/server/server.go` package (43 lines) that wrapped TLS/HTTP server functionality
- Added explicit TCP listener and integrated zerolog with server's error logging
- Cleaned up 17 unused indirect agent dependencies
2026-01-09 20:27:48 +08:00
yusing
74f97a6621 refactor(homepage): reorganize icons into dedicated package structure
Split the monolithic `internal/homepage` icons functionality into a structured package hierarchy:

- `internal/homepage/icons/` - Core types (URL, Key, Meta, Provider, Source, Variant)
- `internal/homepage/icons/fetch/` - Icon fetching logic (content.go, fetch.go, route.go)
- `internal/homepage/icons/list/` - Icon listing and search (list_icons.go, list_icons_test.go)

Moved icon-related code from `internal/homepage/`:
- `icon_url.go` → `icons/url.go` (+ url_test.go)
- `content.go` → `icons/fetch/content.go`
- `route.go` → `icons/fetch/route.go`
- `list_icons.go` → `icons/list/list_icons.go` (+ list_icons_test.go)

Updated all consumers to use the new package structure:
- `cmd/main.go`
- `internal/api/v1/favicon.go`
- `internal/api/v1/icons.go`
- `internal/idlewatcher/handle_http.go`
- `internal/route/route.go`
2026-01-09 12:06:54 +08:00
yusing
dc1b70d2d7 chore(deps): update dependencies 2026-01-09 10:57:24 +08:00
Yuzerion
6fac5d2d3e feat(agent): agent stream tunneling with TLS and dTLS (UDP) (#188)
* **New Features**
  * Multiplexed TLS port: HTTP API and a custom stream protocol can share one port via ALPN.
  * Agent-side TCP and DTLS/UDP stream tunneling with health-check support and runtime capability detection.
  * Agents now advertise per-agent stream support (TCP/UDP).

* **Documentation**
  * Added comprehensive stream protocol documentation.

* **Tests**
  * Extended integration and concurrency tests covering multiplexing, TCP/UDP streams, and health checks.

* **Chores**
  * Compose/template updated to expose both TCP and UDP ports.
2026-01-09 10:52:35 +08:00
61 changed files with 2690 additions and 685 deletions

View File

@@ -3,6 +3,8 @@ export VERSION ?= $(shell git describe --tags --abbrev=0)
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
export GOOS = linux
REPO_URL ?= https://github.com/yusing/godoxy
WEBUI_DIR ?= ../godoxy-webui
DOCS_DIR ?= ${WEBUI_DIR}/wiki
@@ -175,4 +177,4 @@ gen-api-types: gen-swagger
.PHONY: update-wiki
update-wiki:
DOCS_DIR=${DOCS_DIR} bun --bun scripts/update-wiki/main.ts
DOCS_DIR=${DOCS_DIR} REPO_URL=${REPO_URL} bun --bun scripts/update-wiki/main.ts

View File

@@ -1,21 +1,32 @@
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"net"
"net/http"
"os"
stdlog "log"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/agent/pkg/agent/stream"
"github.com/yusing/godoxy/agent/pkg/env"
"github.com/yusing/godoxy/agent/pkg/server"
"github.com/yusing/godoxy/agent/pkg/handler"
"github.com/yusing/godoxy/internal/metrics/systeminfo"
socketproxy "github.com/yusing/godoxy/socketproxy/pkg"
httpServer "github.com/yusing/goutils/server"
gperr "github.com/yusing/goutils/errs"
strutils "github.com/yusing/goutils/strings"
"github.com/yusing/goutils/task"
"github.com/yusing/goutils/version"
)
// TODO: support IPv6
func main() {
writer := zerolog.ConsoleWriter{
Out: os.Stderr,
@@ -52,27 +63,102 @@ func main() {
Tips:
1. To change the agent name, you can set the AGENT_NAME environment variable.
2. To change the agent port, you can set the AGENT_PORT environment variable.
`)
`)
t := task.RootTask("agent", false)
opts := server.Options{
CACert: caCert,
ServerCert: srvCert,
Port: env.AgentPort,
// One TCP listener on AGENT_PORT, then multiplex by TLS ALPN:
// - Stream ALPN: route to TCP stream tunnel handler (via http.Server.TLSNextProto)
// - Otherwise: route to HTTPS API handler
tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{Port: env.AgentPort})
if err != nil {
gperr.LogFatal("failed to listen on port", err)
}
server.StartAgentServer(t, opts)
caCertPool := x509.NewCertPool()
caCertPool.AddCert(caCert.Leaf)
muxTLSConfig := &tls.Config{
Certificates: []tls.Certificate{*srvCert},
ClientCAs: caCertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS12,
// Keep HTTP limited to HTTP/1.1 (matching current agent server behavior)
// and add the stream tunnel ALPN for multiplexing.
NextProtos: []string{"http/1.1", stream.StreamALPN},
}
if env.AgentSkipClientCertCheck {
muxTLSConfig.ClientAuth = tls.NoClientCert
}
// TLS listener feeds the HTTP server. ALPN stream connections are intercepted
// using http.Server.TLSNextProto.
tlsLn := tls.NewListener(tcpListener, muxTLSConfig)
streamSrv := stream.NewTCPServerHandler(t.Context())
httpSrv := &http.Server{
Handler: handler.NewAgentHandler(),
BaseContext: func(net.Listener) context.Context {
return t.Context()
},
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){
// When a client negotiates StreamALPN, net/http will call this hook instead
// of treating the connection as HTTP.
stream.StreamALPN: func(_ *http.Server, conn *tls.Conn, _ http.Handler) {
// ServeConn blocks until the tunnel finishes.
streamSrv.ServeConn(conn)
},
},
}
{
subtask := t.Subtask("agent-http", true)
t.OnCancel("stop_http", func() {
_ = streamSrv.Close()
_ = httpSrv.Close()
_ = tlsLn.Close()
})
go func() {
err := httpSrv.Serve(tlsLn)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error().Err(err).Msg("agent HTTP server stopped with error")
}
subtask.Finish(err)
}()
log.Info().Int("port", env.AgentPort).Msg("HTTPS API server started (ALPN mux enabled)")
}
log.Info().Int("port", env.AgentPort).Msg("TCP stream handler started (via TLSNextProto)")
{
udpServer := stream.NewUDPServer(t.Context(), "udp", &net.UDPAddr{Port: env.AgentPort}, caCert.Leaf, srvCert)
subtask := t.Subtask("agent-stream-udp", true)
t.OnCancel("stop_stream_udp", func() {
_ = udpServer.Close()
})
go func() {
err := udpServer.Start()
subtask.Finish(err)
}()
log.Info().Int("port", env.AgentPort).Msg("UDP stream server started")
}
if socketproxy.ListenAddr != "" {
runtime := strutils.Title(string(env.Runtime))
log.Info().Msgf("%s socket listening on: %s", runtime, socketproxy.ListenAddr)
opts := httpServer.Options{
Name: runtime,
HTTPAddr: socketproxy.ListenAddr,
Handler: socketproxy.NewHandler(),
l, err := net.Listen("tcp", socketproxy.ListenAddr)
if err != nil {
gperr.LogFatal("failed to listen on port", err)
}
httpServer.StartServer(t, opts)
errLog := log.Logger.With().Str("level", "error").Str("component", "socketproxy").Logger()
srv := http.Server{
Handler: socketproxy.NewHandler(),
BaseContext: func(net.Listener) context.Context {
return t.Context()
},
ErrorLog: stdlog.New(&errLog, "", 0),
}
srv.Serve(l)
}
systeminfo.Poller.Start()

View File

@@ -18,22 +18,20 @@ require (
github.com/bytedance/sonic v1.14.2
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
github.com/pion/dtls/v3 v3.0.10
github.com/pion/transport/v3 v3.1.1
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
github.com/yusing/godoxy v0.0.0-00010101000000-000000000000
github.com/yusing/godoxy v0.23.1
github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000
github.com/yusing/goutils v0.7.0
github.com/yusing/goutils/server v0.0.0-20260103043911-785deb23bd64
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
@@ -54,13 +52,12 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -72,15 +69,13 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/puzpuzpuz/xsync/v4 v4.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/samber/slog-common v0.19.0 // indirect
github.com/samber/slog-zerolog/v2 v2.9.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.12 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
@@ -88,12 +83,11 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.68.0 // indirect
github.com/vincent-petithory/dataurl v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/yusing/ds v0.3.1 // indirect
github.com/yusing/gointernals v0.1.16 // indirect
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260103043911-785deb23bd64 // indirect
github.com/yusing/goutils/http/websocket v0.0.0-20260103043911-785deb23bd64 // indirect
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260109021609-78fda75d1e58 // indirect
github.com/yusing/goutils/http/websocket v0.0.0-20260109021609-78fda75d1e58 // 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
@@ -103,7 +97,7 @@ require (
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -55,8 +55,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-acme/lego/v4 v4.30.1 h1:tmb6U0lvy8Mc3lQbqKwTat7oAhE8FUYNJ3D0gSg6pJU=
github.com/go-acme/lego/v4 v4.30.1/go.mod h1:V7m/Ip+EeFkjOe028+zeH+SwWtESxw1LHelwMIfAjm4=
github.com/go-acme/lego/v4 v4.31.0 h1:gd4oUYdfs83PR1/SflkNdit9xY1iul2I4EystnU8NXM=
github.com/go-acme/lego/v4 v4.31.0/go.mod h1:m6zcfX/zcbMYDa8s6AnCMnoORWNP8Epnei+6NBCTUGs=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -79,12 +79,11 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
@@ -114,8 +113,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/luthermonson/go-proxmox v0.3.1 h1:h64s4/zIEQ06TBo0phFKcckV441YpvUPgLfRAptYsjY=
github.com/luthermonson/go-proxmox v0.3.1/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/luthermonson/go-proxmox v0.3.2 h1:/zUg6FCl9cAABx0xU3OIgtDtClY0gVXxOCsrceDNylc=
github.com/luthermonson/go-proxmox v0.3.2/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -146,6 +145,14 @@ github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
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/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
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.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -196,13 +203,12 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusing/ds v0.3.1 h1:mCqTgTQD8RhiBpcysvii5kZ7ZBmqcknVsFubNALGLbY=
github.com/yusing/ds v0.3.1/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
@@ -229,93 +235,31 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -27,26 +27,26 @@ graph TD
## File Structure
| File | Purpose |
| -------------------------------------------------------- | --------------------------------------------------------- |
| [`config.go`](agent/pkg/agent/config.go) | Core configuration, initialization, and API client logic. |
| [`new_agent.go`](agent/pkg/agent/new_agent.go) | Agent creation and certificate generation logic. |
| [`docker_compose.go`](agent/pkg/agent/docker_compose.go) | Generator for agent Docker Compose configurations. |
| [`bare_metal.go`](agent/pkg/agent/bare_metal.go) | Generator for bare metal installation scripts. |
| [`env.go`](agent/pkg/agent/env.go) | Environment configuration types and constants. |
| [`common/`](agent/pkg/agent/common) | Shared constants and utilities for agents. |
| File | Purpose |
| ---------------------------------------- | --------------------------------------------------------- |
| [`config.go`](config.go) | Core configuration, initialization, and API client logic. |
| [`new_agent.go`](new_agent.go) | Agent creation and certificate generation logic. |
| [`docker_compose.go`](docker_compose.go) | Generator for agent Docker Compose configurations. |
| [`bare_metal.go`](bare_metal.go) | Generator for bare metal installation scripts. |
| [`env.go`](env.go) | Environment configuration types and constants. |
| `common/` | Shared constants and utilities for agents. |
## Core Types
### [`AgentConfig`](agent/pkg/agent/config.go:29)
### [`AgentConfig`](config.go:29)
The primary struct used by the GoDoxy server to manage a connection to an agent. It stores the agent's address, metadata, and TLS configuration.
### [`AgentInfo`](agent/pkg/agent/config.go:45)
### [`AgentInfo`](config.go:45)
Contains basic metadata about the agent, including its version, name, and container runtime (Docker or Podman).
### [`PEMPair`](agent/pkg/agent/new_agent.go:53)
### [`PEMPair`](new_agent.go:53)
A utility struct for handling PEM-encoded certificate and key pairs, supporting encryption, decryption, and conversion to `tls.Certificate`.
@@ -54,7 +54,7 @@ A utility struct for handling PEM-encoded certificate and key pairs, supporting
### Certificate Generation
The [`NewAgent`](agent/pkg/agent/new_agent.go:147) function creates a complete certificate infrastructure for an agent:
The [`NewAgent`](new_agent.go:147) function creates a complete certificate infrastructure for an agent:
- **CA Certificate**: Self-signed root certificate with 1000-year validity.
- **Server Certificate**: For the agent's HTTPS server, signed by the CA.
@@ -65,18 +65,18 @@ All certificates use ECDSA with P-256 curve and SHA-256 signatures.
### Certificate Security
- Certificates are encrypted using AES-GCM with a provided encryption key.
- The [`PEMPair`](agent/pkg/agent/new_agent.go:53) struct provides methods for encryption, decryption, and conversion to `tls.Certificate`.
- The [`PEMPair`](new_agent.go:53) struct provides methods for encryption, decryption, and conversion to `tls.Certificate`.
- Base64 encoding is used for certificate storage and transmission.
## Key Features
### 1. Secure Communication
All communication between the GoDoxy server and agents is secured using mutual TLS (mTLS). The [`AgentConfig`](agent/pkg/agent/config.go:29) handles the loading of CA and client certificates to establish secure connections.
All communication between the GoDoxy server and agents is secured using mutual TLS (mTLS). The [`AgentConfig`](config.go:29) handles the loading of CA and client certificates to establish secure connections.
### 2. Agent Discovery and Initialization
The [`Init`](agent/pkg/agent/config.go:231) and [`InitWithCerts`](agent/pkg/agent/config.go:110) methods allow the server to:
The [`Init`](config.go:231) and [`InitWithCerts`](config.go:110) methods allow the server to:
- Fetch agent metadata (version, name, runtime).
- Verify compatibility between server and agent versions.
@@ -86,12 +86,12 @@ The [`Init`](agent/pkg/agent/config.go:231) and [`InitWithCerts`](agent/pkg/agen
The package provides interfaces and implementations for generating deployment artifacts:
- **Docker Compose**: Generates a `docker-compose.yml` for running the agent as a container via [`AgentComposeConfig.Generate()`](agent/pkg/agent/docker_compose.go:21).
- **Bare Metal**: Generates a shell script to install and run the agent as a systemd service via [`AgentEnvConfig.Generate()`](agent/pkg/agent/bare_metal.go:27).
- **Docker Compose**: Generates a `docker-compose.yml` for running the agent as a container via [`AgentComposeConfig.Generate()`](docker_compose.go:21).
- **Bare Metal**: Generates a shell script to install and run the agent as a systemd service via [`AgentEnvConfig.Generate()`](bare_metal.go:27).
### 4. Fake Docker Host
The package supports a "fake" Docker host scheme (`agent://<addr>`) to identify containers managed by an agent, allowing the GoDoxy server to route requests appropriately. See [`IsDockerHostAgent`](agent/pkg/agent/config.go:90) and [`GetAgentAddrFromDockerHost`](agent/pkg/agent/config.go:94).
The package supports a "fake" Docker host scheme (`agent://<addr>`) to identify containers managed by an agent, allowing the GoDoxy server to route requests appropriately. See [`IsDockerHostAgent`](config.go:90) and [`GetAgentAddrFromDockerHost`](config.go:94).
## Usage Example

View File

@@ -0,0 +1,3 @@
package common
const CertsDNSName = "godoxy.agent"

View File

@@ -4,6 +4,8 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
@@ -16,31 +18,51 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/agent/common"
agentstream "github.com/yusing/godoxy/agent/pkg/agent/stream"
"github.com/yusing/godoxy/agent/pkg/certs"
gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/version"
)
type AgentConfig struct {
Addr string `json:"addr"`
Name string `json:"name"`
Version version.Version `json:"version" swaggertype:"string"`
Runtime ContainerRuntime `json:"runtime"`
AgentInfo
Addr string `json:"addr"`
IsTCPStreamSupported bool `json:"supports_tcp_stream"`
IsUDPStreamSupported bool `json:"supports_udp_stream"`
// for stream
caCert *x509.Certificate
clientCert *tls.Certificate
tlsConfig tls.Config
l zerolog.Logger
l zerolog.Logger
} // @name Agent
type AgentInfo struct {
Version version.Version `json:"version" swaggertype:"string"`
Name string `json:"name"`
Runtime ContainerRuntime `json:"runtime"`
}
// Deprecated. Replaced by EndpointInfo
const (
EndpointVersion = "/version"
EndpointName = "/name"
EndpointRuntime = "/runtime"
EndpointVersion = "/version"
EndpointName = "/name"
EndpointRuntime = "/runtime"
)
const (
EndpointInfo = "/info"
EndpointProxyHTTP = "/proxy/http"
EndpointHealth = "/health"
EndpointLogs = "/logs"
EndpointSystemInfo = "/system_info"
AgentHost = CertsDNSName
AgentHost = common.CertsDNSName
APIEndpointBase = "/godoxy/agent"
APIBaseURL = "https://" + AgentHost + APIEndpointBase
@@ -90,6 +112,7 @@ func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte)
if err != nil {
return err
}
cfg.clientCert = &clientCert
// create tls config
caCertPool := x509.NewCertPool()
@@ -97,58 +120,105 @@ func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte)
if !ok {
return errors.New("invalid ca certificate")
}
// Keep the CA leaf for stream client dialing.
if block, _ := pem.Decode(ca); block == nil || block.Type != "CERTIFICATE" {
return errors.New("invalid ca certificate")
} else if cert, err := x509.ParseCertificate(block.Bytes); err != nil {
return err
} else {
cfg.caCert = cert
}
cfg.tlsConfig = tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caCertPool,
ServerName: CertsDNSName,
ServerName: common.CertsDNSName,
MinVersion: tls.VersionTLS12,
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// get agent name
name, _, err := cfg.fetchString(ctx, EndpointName)
status, err := cfg.fetchJSON(ctx, EndpointInfo, &cfg.AgentInfo)
if err != nil {
return err
}
cfg.Name = name
var streamUnsupportedErrs gperr.Builder
if status == http.StatusOK {
// 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)
if err != nil {
streamUnsupportedErrs.Addf("failed to connect to stream server via TCP: %w", err)
} else {
cfg.IsTCPStreamSupported = true
}
// test UDP stream support
err = agentstream.UDPHealthCheck(cfg.Addr, cfg.caCert, cfg.clientCert)
if err != nil {
streamUnsupportedErrs.Addf("failed to connect to stream server via UDP: %w", err)
} else {
cfg.IsUDPStreamSupported = true
}
} else {
// old agent does not support EndpointInfo
// fallback with old logic
cfg.IsTCPStreamSupported = false
cfg.IsUDPStreamSupported = false
streamUnsupportedErrs.Adds("agent version is too old, does not support stream tunneling")
// get agent name
name, _, err := cfg.fetchString(ctx, EndpointName)
if err != nil {
return err
}
cfg.Name = name
// check agent version
agentVersion, _, err := cfg.fetchString(ctx, EndpointVersion)
if err != nil {
return err
}
cfg.Version = version.Parse(agentVersion)
// check agent runtime
runtime, status, err := cfg.fetchString(ctx, EndpointRuntime)
if err != nil {
return err
}
switch status {
case http.StatusOK:
switch runtime {
case "docker":
cfg.Runtime = ContainerRuntimeDocker
// case "nerdctl":
// cfg.Runtime = ContainerRuntimeNerdctl
case "podman":
cfg.Runtime = ContainerRuntimePodman
default:
return fmt.Errorf("invalid agent runtime: %s", runtime)
}
case http.StatusNotFound:
// backward compatibility, old agent does not have runtime endpoint
cfg.Runtime = ContainerRuntimeDocker
default:
return fmt.Errorf("failed to get agent runtime: HTTP %d %s", status, runtime)
}
}
cfg.l = log.With().Str("agent", cfg.Name).Logger()
// check agent version
agentVersion, _, err := cfg.fetchString(ctx, EndpointVersion)
if err != nil {
return err
if err := streamUnsupportedErrs.Error(); err != nil {
gperr.LogWarn("agent has limited/no stream tunneling support, TCP and UDP routes via agent will not work", err, &cfg.l)
}
// check agent runtime
runtime, status, err := cfg.fetchString(ctx, EndpointRuntime)
if err != nil {
return err
}
switch status {
case http.StatusOK:
switch runtime {
case "docker":
cfg.Runtime = ContainerRuntimeDocker
// case "nerdctl":
// cfg.Runtime = ContainerRuntimeNerdctl
case "podman":
cfg.Runtime = ContainerRuntimePodman
default:
return fmt.Errorf("invalid agent runtime: %s", runtime)
}
case http.StatusNotFound:
// backward compatibility, old agent does not have runtime endpoint
cfg.Runtime = ContainerRuntimeDocker
default:
return fmt.Errorf("failed to get agent runtime: HTTP %d %s", status, runtime)
}
cfg.Version = version.Parse(agentVersion)
if serverVersion.IsNewerThanMajor(cfg.Version) {
log.Warn().Msgf("agent %s major version mismatch: server: %s, agent: %s", cfg.Name, serverVersion, cfg.Version)
}
@@ -177,6 +247,38 @@ func (cfg *AgentConfig) Init(ctx context.Context) error {
return cfg.InitWithCerts(ctx, ca, crt, key)
}
// NewTCPClient creates a new TCP client for the agent.
//
// It returns an error if
// - the agent is not initialized
// - the agent does not support TCP stream tunneling
// - the agent stream server address is not initialized
func (cfg *AgentConfig) NewTCPClient(targetAddress string) (net.Conn, error) {
if cfg.caCert == nil || cfg.clientCert == nil {
return nil, errors.New("agent is not initialized")
}
if !cfg.IsTCPStreamSupported {
return nil, errors.New("agent does not support TCP stream tunneling")
}
return agentstream.NewTCPClient(cfg.Addr, targetAddress, cfg.caCert, cfg.clientCert)
}
// NewUDPClient creates a new UDP client for the agent.
//
// It returns an error if
// - the agent is not initialized
// - the agent does not support UDP stream tunneling
// - the agent stream server address is not initialized
func (cfg *AgentConfig) NewUDPClient(targetAddress string) (net.Conn, error) {
if cfg.caCert == nil || cfg.clientCert == nil {
return nil, errors.New("agent is not initialized")
}
if !cfg.IsUDPStreamSupported {
return nil, errors.New("agent does not support UDP stream tunneling")
}
return agentstream.NewUDPClient(cfg.Addr, targetAddress, cfg.caCert, cfg.clientCert)
}
func (cfg *AgentConfig) Transport() *http.Transport {
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
@@ -232,3 +334,31 @@ func (cfg *AgentConfig) fetchString(ctx context.Context, endpoint string) (strin
release(data)
return ret, resp.StatusCode, nil
}
// fetchJSON fetches a JSON response from the agent and unmarshals it into the provided struct
//
// It will return the status code of the response, and error if any.
// If the status code is not http.StatusOK, out will be unchanged but error will still be nil.
func (cfg *AgentConfig) fetchJSON(ctx context.Context, endpoint string, out any) (int, error) {
resp, err := cfg.do(ctx, "GET", endpoint, nil)
if err != nil {
return 0, err
}
defer resp.Body.Close()
data, release, err := httputils.ReadAllBody(resp)
if err != nil {
return 0, err
}
defer release(data)
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, nil
}
err = json.Unmarshal(data, out)
if err != nil {
return 0, err
}
return resp.StatusCode, nil
}

View File

@@ -17,10 +17,8 @@ import (
"math/big"
"strings"
"time"
)
const (
CertsDNSName = "godoxy.agent"
"github.com/yusing/godoxy/agent/pkg/agent/common"
)
func toPEMPair(certDER []byte, key *ecdsa.PrivateKey) *PEMPair {
@@ -156,7 +154,7 @@ func NewAgent() (ca, srv, client *PEMPair, err error) {
SerialNumber: caSerialNumber,
Subject: pkix.Name{
Organization: []string{"GoDoxy"},
CommonName: CertsDNSName,
CommonName: common.CertsDNSName,
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1000, 0, 0), // 1000 years
@@ -196,9 +194,9 @@ func NewAgent() (ca, srv, client *PEMPair, err error) {
Subject: pkix.Name{
Organization: caTemplate.Subject.Organization,
OrganizationalUnit: []string{"Server"},
CommonName: CertsDNSName,
CommonName: common.CertsDNSName,
},
DNSNames: []string{CertsDNSName},
DNSNames: []string{common.CertsDNSName},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1000, 0, 0), // Add validity period
KeyUsage: x509.KeyUsageDigitalSignature,
@@ -228,9 +226,9 @@ func NewAgent() (ca, srv, client *PEMPair, err error) {
Subject: pkix.Name{
Organization: caTemplate.Subject.Organization,
OrganizationalUnit: []string{"Client"},
CommonName: CertsDNSName,
CommonName: common.CertsDNSName,
},
DNSNames: []string{CertsDNSName},
DNSNames: []string{common.CertsDNSName},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1000, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,

View File

@@ -10,6 +10,7 @@ import (
"testing"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/agent/pkg/agent/common"
)
func TestNewAgent(t *testing.T) {
@@ -72,7 +73,7 @@ func TestServerClient(t *testing.T) {
clientTLSConfig := &tls.Config{
Certificates: []tls.Certificate{*clientTLS},
RootCAs: caPool,
ServerName: CertsDNSName,
ServerName: common.CertsDNSName,
}
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,197 @@
# Stream proxy protocol
This package implements a small header-based handshake that allows an authenticated client to request forwarding to a `(host, port)` destination. It supports both TCP-over-TLS and UDP-over-DTLS transports.
## Overview
```mermaid
graph TD
subgraph Client
TC[TCPClient] -->|TLS| TSS[TCPServer]
UC[UDPClient] -->|DTLS| USS[UDPServer]
end
subgraph Stream Protocol
H[StreamRequestHeader]
end
TSS -->|Redirect| DST1[Destination TCP]
USS -->|Forward UDP| DST2[Destination UDP]
```
## Header
The on-wire header is a fixed-size binary blob:
- `Version` (8 bytes)
- `HostLength` (1 byte)
- `Host` (255 bytes, NUL padded)
- `PortLength` (1 byte)
- `Port` (5 bytes, NUL padded)
- `Flag` (1 byte, protocol flags)
- `Checksum` (4 bytes, big-endian CRC32)
Total: `headerSize = 8 + 1 + 255 + 1 + 5 + 1 + 4 = 275` bytes.
Checksum is `crc32.ChecksumIEEE(header[0:headerSize-4])`.
### Flags
The `Flag` field is a bitmask of protocol flags defined by `FlagType`:
| Flag | Value | Purpose |
| ---------------------- | ----- | ---------------------------------------------------------------------- |
| `FlagCloseImmediately` | `1` | Health check probe - server closes immediately after validating header |
See [`FlagType`](header.go:26) and [`FlagCloseImmediately`](header.go:28).
See [`StreamRequestHeader`](header.go:30).
## File Structure
| File | Purpose |
| ----------------------------------- | ------------------------------------------------------------ |
| [`header.go`](header.go) | Stream request header structure and validation. |
| [`tcp_client.go`](tcp_client.go:12) | TCP client implementation with TLS transport. |
| [`tcp_server.go`](tcp_server.go:13) | TCP server implementation for handling stream requests. |
| [`udp_client.go`](udp_client.go:13) | UDP client implementation with DTLS transport. |
| [`udp_server.go`](udp_server.go:17) | UDP server implementation for handling DTLS stream requests. |
| [`common.go`](common.go:11) | Connection manager and shared constants. |
## Constants
| Constant | Value | Purpose |
| ---------------------- | ------------------------- | ------------------------------------------------------- |
| `StreamALPN` | `"godoxy-agent-stream/1"` | TLS ALPN protocol for stream multiplexing. |
| `headerSize` | `275` bytes | Total size of the stream request header. |
| `dialTimeout` | `10s` | Timeout for establishing destination connections. |
| `readDeadline` | `10s` | Read timeout for UDP destination sockets. |
| `FlagCloseImmediately` | `1` | Flag for health check probe - server closes immediately |
See [`common.go`](common.go:11).
## Public API
### Types
#### `StreamRequestHeader`
Represents the on-wire protocol header used to negotiate a stream tunnel.
```go
type StreamRequestHeader struct {
Version [8]byte // Fixed to "0.1.0" with NUL padding
HostLength byte // Actual host name length (0-255)
Host [255]byte // NUL-padded host name
PortLength byte // Actual port string length (0-5)
Port [5]byte // NUL-padded port string
Flag FlagType // Protocol flags (e.g., FlagCloseImmediately)
Checksum [4]byte // CRC32 checksum of header without checksum
}
```
**Methods:**
- `NewStreamRequestHeader(host, port string) (*StreamRequestHeader, error)` - Creates a header for the given host and port. Returns error if host exceeds 255 bytes or port exceeds 5 bytes.
- `NewStreamHealthCheckHeader() *StreamRequestHeader` - Creates a header with `FlagCloseImmediately` set for health check probes.
- `Validate() bool` - Validates the version and checksum.
- `GetHostPort() (string, string)` - Extracts the host and port from the header.
- `ShouldCloseImmediately() bool` - Returns true if `FlagCloseImmediately` is set.
### TCP Functions
- [`NewTCPClient()`](tcp_client.go:26) - Creates a TLS client connection and sends the stream header.
- [`NewTCPServerHandler()`](tcp_server.go:24) - Creates a handler for ALPN-multiplexed connections (no listener).
- [`NewTCPServerFromListener()`](tcp_server.go:36) - Wraps an existing TLS listener.
- [`NewTCPServer()`](tcp_server.go:45) - Creates a fully-configured TCP server with TLS listener.
### UDP Functions
- [`NewUDPClient()`](udp_client.go:27) - Creates a DTLS client connection and sends the stream header.
- [`NewUDPServer()`](udp_server.go:26) - Creates a DTLS server listening on the given UDP address.
## Health Check Probes
The protocol supports health check probes using the `FlagCloseImmediately` flag. When a client sends a header with this flag set, the server validates the header and immediately closes the connection without establishing a destination tunnel.
This is useful for:
- Connectivity testing between agent and server
- Verifying TLS/DTLS handshake and mTLS authentication
- Monitoring stream protocol availability
**Usage:**
```go
header := stream.NewStreamHealthCheckHeader()
// Send header over TLS/DTLS connection
// Server will validate and close immediately
```
Both TCP and UDP servers silently handle health check probes without logging errors.
See [`NewStreamHealthCheckHeader()`](header.go:66) and [`FlagCloseImmediately`](header.go:28).
## TCP behavior
1. Client establishes a TLS connection to the stream server.
2. Client sends exactly one header as a handshake.
3. After the handshake, both sides proxy raw TCP bytes between client and destination.
Server reads the header using `io.ReadFull` to avoid dropping bytes.
See [`NewTCPClient()`](tcp_client.go:26) and [`(*TCPServer).redirect()`](tcp_server.go:116).
## UDP-over-DTLS behavior
1. Client establishes a DTLS connection to the stream server.
2. Client sends exactly one header as a handshake.
3. After the handshake, both sides proxy raw UDP datagrams:
- client -> destination: DTLS payload is written to destination `UDPConn`
- destination -> client: destination payload is written back to the DTLS connection
Responses do **not** include a header.
The UDP server uses a bidirectional forwarding model:
- One goroutine forwards from client to destination
- Another goroutine forwards from destination to client
The destination reader uses `readDeadline` to periodically wake up and check for context cancellation. Timeouts do not terminate the session.
See [`NewUDPClient()`](udp_client.go:27) and [`(*UDPServer).handleDTLSConnection()`](udp_server.go:89).
## Connection Management
Both `TCPServer` and `UDPServer` create a dedicated destination connection per incoming stream session and close it when the session ends (no destination connection reuse).
## Error Handling
| Error | Description |
| --------------------- | ----------------------------------------------- |
| `ErrInvalidHeader` | Header validation failed (version or checksum). |
| `ErrCloseImmediately` | Health check probe - server closed immediately. |
Errors from connection creation are propagated to the caller.
See [`header.go`](header.go:23).
## Integration
This package is used by the agent to provide stream tunneling capabilities. See the parent [`agent`](../README.md) package for integration details with the GoDoxy server.
### Certificate Requirements
Both TCP and UDP servers require:
- CA certificate for client verification
- Server certificate for TLS/DTLS termination
Both clients require:
- CA certificate for server verification
- Client certificate for mTLS authentication
### ALPN Protocol
The `StreamALPN` constant (`"godoxy-agent-stream/1"`) is used to multiplex stream tunnel traffic and HTTPS API traffic on the same port. Connections negotiating this ALPN are routed to the stream handler.

View File

@@ -0,0 +1,24 @@
package stream
import (
"time"
"github.com/pion/dtls/v3"
"github.com/yusing/goutils/synk"
)
const (
dialTimeout = 10 * time.Second
readDeadline = 10 * time.Second
)
// StreamALPN is the TLS ALPN protocol id used to multiplex the TCP stream tunnel
// and the HTTPS API on the same TCP port.
//
// When a client negotiates this ALPN, the agent will route the connection to the
// stream tunnel handler instead of the HTTP handler.
const StreamALPN = "godoxy-agent-stream/1"
var dTLSCipherSuites = []dtls.CipherSuiteID{dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}
var sizedPool = synk.GetSizedBytesPool()

View File

@@ -0,0 +1,117 @@
package stream
import (
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"reflect"
"unsafe"
)
const (
versionSize = 8
hostSize = 255
portSize = 5
flagSize = 1
checksumSize = 4 // crc32 checksum
headerSize = versionSize + 1 + hostSize + 1 + portSize + flagSize + checksumSize
)
var version = [versionSize]byte{'0', '.', '1', '.', '0', 0, 0, 0}
var ErrInvalidHeader = errors.New("invalid header")
var ErrCloseImmediately = errors.New("close immediately")
type FlagType uint8
const FlagCloseImmediately FlagType = 1 << iota
type StreamRequestHeader struct {
Version [versionSize]byte
HostLength byte
Host [hostSize]byte
PortLength byte
Port [portSize]byte
Flag FlagType
Checksum [checksumSize]byte
}
func init() {
if headerSize != reflect.TypeFor[StreamRequestHeader]().Size() {
panic("headerSize does not match the size of StreamRequestHeader")
}
}
func NewStreamRequestHeader(host, port string) (*StreamRequestHeader, error) {
if len(host) > hostSize {
return nil, fmt.Errorf("host is too long: max %d characters, got %d", hostSize, len(host))
}
if len(port) > portSize {
return nil, fmt.Errorf("port is too long: max %d characters, got %d", portSize, len(port))
}
header := &StreamRequestHeader{}
copy(header.Version[:], version[:])
header.HostLength = byte(len(host))
copy(header.Host[:], host)
header.PortLength = byte(len(port))
copy(header.Port[:], port)
header.updateChecksum()
return header, nil
}
func NewStreamHealthCheckHeader() *StreamRequestHeader {
header := &StreamRequestHeader{}
copy(header.Version[:], version[:])
header.Flag |= FlagCloseImmediately
header.updateChecksum()
return header
}
// ToHeader converts header byte array to a copy of itself as a StreamRequestHeader.
func ToHeader(buf *[headerSize]byte) StreamRequestHeader {
return *(*StreamRequestHeader)(unsafe.Pointer(buf))
}
func (h *StreamRequestHeader) GetHostPort() (string, string) {
return string(h.Host[:h.HostLength]), string(h.Port[:h.PortLength])
}
func (h *StreamRequestHeader) Validate() bool {
if h.Version != version {
return false
}
if h.HostLength > hostSize {
return false
}
if h.PortLength > portSize {
return false
}
return h.validateChecksum()
}
func (h *StreamRequestHeader) ShouldCloseImmediately() bool {
return h.Flag&FlagCloseImmediately != 0
}
func (h *StreamRequestHeader) updateChecksum() {
checksum := crc32.ChecksumIEEE(h.BytesWithoutChecksum())
binary.BigEndian.PutUint32(h.Checksum[:], checksum)
}
func (h *StreamRequestHeader) validateChecksum() bool {
checksum := crc32.ChecksumIEEE(h.BytesWithoutChecksum())
return checksum == binary.BigEndian.Uint32(h.Checksum[:])
}
func (h *StreamRequestHeader) BytesWithoutChecksum() []byte {
return (*[headerSize - checksumSize]byte)(unsafe.Pointer(h))[:]
}
func (h *StreamRequestHeader) Bytes() []byte {
return (*[headerSize]byte)(unsafe.Pointer(h))[:]
}

View File

@@ -0,0 +1,26 @@
package stream
import (
"testing"
)
func TestStreamRequestHeader_RoundTripAndChecksum(t *testing.T) {
h, err := NewStreamRequestHeader("example.com", "443")
if err != nil {
t.Fatalf("NewStreamRequestHeader: %v", err)
}
if !h.Validate() {
t.Fatalf("expected header to validate")
}
var buf [headerSize]byte
copy(buf[:], h.Bytes())
h2 := ToHeader(&buf)
if !h2.Validate() {
t.Fatalf("expected round-tripped header to validate")
}
host, port := h2.GetHostPort()
if host != "example.com" || port != "443" {
t.Fatalf("unexpected host/port: %q:%q", host, port)
}
}

View File

@@ -0,0 +1,122 @@
package stream
import (
"crypto/tls"
"crypto/x509"
"net"
"time"
"github.com/yusing/godoxy/agent/pkg/agent/common"
)
type TCPClient struct {
conn net.Conn
}
// NewTCPClient creates a new TCP client for the agent.
//
// It will establish a TLS connection and send a stream request header to the server.
//
// It returns an error if
// - the target address is invalid
// - the stream request header is invalid
// - the TLS configuration is invalid
// - the TLS connection fails
// - the stream request header is not sent
func NewTCPClient(serverAddr, targetAddress string, caCert *x509.Certificate, clientCert *tls.Certificate) (net.Conn, error) {
host, port, err := net.SplitHostPort(targetAddress)
if err != nil {
return nil, err
}
header, err := NewStreamRequestHeader(host, port)
if err != nil {
return nil, err
}
return newTCPClientWIthHeader(serverAddr, header, caCert, clientCert)
}
func TCPHealthCheck(serverAddr string, caCert *x509.Certificate, clientCert *tls.Certificate) error {
header := NewStreamHealthCheckHeader()
conn, err := newTCPClientWIthHeader(serverAddr, header, caCert, clientCert)
if err != nil {
return err
}
conn.Close()
return nil
}
func newTCPClientWIthHeader(serverAddr string, header *StreamRequestHeader, caCert *x509.Certificate, clientCert *tls.Certificate) (net.Conn, error) {
// Setup TLS configuration
caCertPool := x509.NewCertPool()
caCertPool.AddCert(caCert)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{*clientCert},
RootCAs: caCertPool,
MinVersion: tls.VersionTLS12,
NextProtos: []string{StreamALPN},
ServerName: common.CertsDNSName,
}
// Establish TLS connection
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: dialTimeout}, "tcp", serverAddr, tlsConfig)
if err != nil {
return nil, err
}
// Send the stream header once as a handshake.
if _, err := conn.Write(header.Bytes()); err != nil {
_ = conn.Close()
return nil, err
}
return &TCPClient{
conn: conn,
}, nil
}
func (c *TCPClient) Read(p []byte) (n int, err error) {
return c.conn.Read(p)
}
func (c *TCPClient) Write(p []byte) (n int, err error) {
return c.conn.Write(p)
}
func (c *TCPClient) LocalAddr() net.Addr {
return c.conn.LocalAddr()
}
func (c *TCPClient) RemoteAddr() net.Addr {
return c.conn.RemoteAddr()
}
func (c *TCPClient) SetDeadline(t time.Time) error {
return c.conn.SetDeadline(t)
}
func (c *TCPClient) SetReadDeadline(t time.Time) error {
return c.conn.SetReadDeadline(t)
}
func (c *TCPClient) SetWriteDeadline(t time.Time) error {
return c.conn.SetWriteDeadline(t)
}
func (c *TCPClient) Close() error {
return c.conn.Close()
}
// ConnectionState exposes the underlying TLS connection state when the client is
// backed by *tls.Conn.
//
// This is primarily used by tests and diagnostics.
func (c *TCPClient) ConnectionState() tls.ConnectionState {
if tc, ok := c.conn.(*tls.Conn); ok {
return tc.ConnectionState()
}
return tls.ConnectionState{}
}

View File

@@ -0,0 +1,179 @@
package stream
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"io"
"net"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
ioutils "github.com/yusing/goutils/io"
)
type TCPServer struct {
ctx context.Context
listener net.Listener
}
// NewTCPServerHandler creates a TCP stream server that can serve already-accepted
// connections (e.g. handed off by an ALPN multiplexer).
//
// This variant does not require a listener. Use TCPServer.ServeConn to handle
// each incoming stream connection.
func NewTCPServerHandler(ctx context.Context) *TCPServer {
s := &TCPServer{ctx: ctx}
return s
}
// NewTCPServerFromListener creates a TCP stream server from an already-prepared
// listener.
//
// The listener is expected to yield connections that are already secured (e.g.
// a TLS/mTLS listener, or pre-handshaked *tls.Conn). This is used when the agent
// multiplexes HTTPS and stream-tunnel traffic on the same port.
func NewTCPServerFromListener(ctx context.Context, listener net.Listener) *TCPServer {
s := &TCPServer{
ctx: ctx,
listener: listener,
}
return s
}
func NewTCPServer(ctx context.Context, listener *net.TCPListener, caCert *x509.Certificate, serverCert *tls.Certificate) *TCPServer {
caCertPool := x509.NewCertPool()
caCertPool.AddCert(caCert)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{*serverCert},
ClientCAs: caCertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS12,
NextProtos: []string{StreamALPN},
}
tcpListener := tls.NewListener(listener, tlsConfig)
return NewTCPServerFromListener(ctx, tcpListener)
}
func (s *TCPServer) Start() error {
if s.listener == nil {
return net.ErrClosed
}
context.AfterFunc(s.ctx, func() {
_ = s.listener.Close()
})
for {
conn, err := s.listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) && s.ctx.Err() != nil {
return s.ctx.Err()
}
return err
}
go s.handle(conn)
}
}
// ServeConn serves a single stream connection.
//
// The provided connection is expected to be already secured (TLS/mTLS) and to
// speak the stream protocol (i.e. the client will send the stream header first).
//
// This method blocks until the stream finishes.
func (s *TCPServer) ServeConn(conn net.Conn) {
s.handle(conn)
}
func (s *TCPServer) Addr() net.Addr {
if s.listener == nil {
return nil
}
return s.listener.Addr()
}
func (s *TCPServer) Close() error {
if s.listener == nil {
return nil
}
return s.listener.Close()
}
func (s *TCPServer) logger(clientConn net.Conn) *zerolog.Logger {
ev := log.With().Str("protocol", "tcp").
Str("remote", clientConn.RemoteAddr().String())
if s.listener != nil {
ev = ev.Str("addr", s.listener.Addr().String())
}
l := ev.Logger()
return &l
}
func (s *TCPServer) loggerWithDst(dstConn net.Conn, clientConn net.Conn) *zerolog.Logger {
ev := log.With().Str("protocol", "tcp").
Str("remote", clientConn.RemoteAddr().String()).
Str("dst", dstConn.RemoteAddr().String())
if s.listener != nil {
ev = ev.Str("addr", s.listener.Addr().String())
}
l := ev.Logger()
return &l
}
func (s *TCPServer) handle(conn net.Conn) {
defer conn.Close()
dst, err := s.redirect(conn)
if err != nil {
// Health check probe: close connection
if errors.Is(err, ErrCloseImmediately) {
s.logger(conn).Info().Msg("Health check received")
return
}
s.logger(conn).Err(err).Msg("failed to redirect connection")
return
}
defer dst.Close()
pipe := ioutils.NewBidirectionalPipe(s.ctx, conn, dst)
err = pipe.Start()
if err != nil {
s.loggerWithDst(dst, conn).Err(err).Msg("failed to start bidirectional pipe")
return
}
}
func (s *TCPServer) redirect(conn net.Conn) (net.Conn, error) {
// Read the stream header once as a handshake.
var headerBuf [headerSize]byte
_ = conn.SetReadDeadline(time.Now().Add(dialTimeout))
if _, err := io.ReadFull(conn, headerBuf[:]); err != nil {
return nil, err
}
_ = conn.SetReadDeadline(time.Time{})
header := ToHeader(&headerBuf)
if !header.Validate() {
return nil, ErrInvalidHeader
}
// Health check: close immediately if FlagCloseImmediately is set
if header.ShouldCloseImmediately() {
return nil, ErrCloseImmediately
}
// get destination connection
host, port := header.GetHostPort()
return s.createDestConnection(host, port)
}
func (s *TCPServer) createDestConnection(host, port string) (net.Conn, error) {
addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil {
return nil, err
}
return conn, nil
}

View File

@@ -0,0 +1,26 @@
package stream_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/agent/pkg/agent/stream"
)
func TestTCPHealthCheck(t *testing.T) {
certs := genTestCerts(t)
srv := startTCPServer(t, certs)
err := stream.TCPHealthCheck(srv.Addr.String(), certs.CaCert, certs.ClientCert)
require.NoError(t, err, "health check")
}
func TestUDPHealthCheck(t *testing.T) {
certs := genTestCerts(t)
srv := startUDPServer(t, certs)
err := stream.UDPHealthCheck(srv.Addr.String(), certs.CaCert, certs.ClientCert)
require.NoError(t, err, "health check")
}

View File

@@ -0,0 +1,94 @@
package stream_test
import (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"io"
"net"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/agent/pkg/agent/common"
"github.com/yusing/godoxy/agent/pkg/agent/stream"
)
func TestTLSALPNMux_HTTPAndStreamShareOnePort(t *testing.T) {
certs := genTestCerts(t)
baseLn, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
require.NoError(t, err, "listen tcp")
defer baseLn.Close()
baseAddr := baseLn.Addr().String()
caCertPool := x509.NewCertPool()
caCertPool.AddCert(certs.CaCert)
serverTLS := &tls.Config{
Certificates: []tls.Certificate{*certs.SrvCert},
ClientCAs: caCertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1", stream.StreamALPN},
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
streamSrv := stream.NewTCPServerHandler(ctx)
defer func() { _ = streamSrv.Close() }()
tlsLn := tls.NewListener(baseLn, serverTLS)
defer func() { _ = tlsLn.Close() }()
// HTTP server
httpSrv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
}),
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){
stream.StreamALPN: func(_ *http.Server, conn *tls.Conn, _ http.Handler) {
streamSrv.ServeConn(conn)
},
},
}
go func() { _ = httpSrv.Serve(tlsLn) }()
defer func() { _ = httpSrv.Close() }()
// Stream destination
dstAddr, closeDst := startTCPEcho(t)
defer closeDst()
// HTTP client over the same port
clientTLS := &tls.Config{
Certificates: []tls.Certificate{*certs.ClientCert},
RootCAs: caCertPool,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
ServerName: common.CertsDNSName,
}
hc, err := tls.Dial("tcp", baseAddr, clientTLS)
require.NoError(t, err, "dial https")
defer hc.Close()
_ = hc.SetDeadline(time.Now().Add(2 * time.Second))
_, err = hc.Write([]byte("GET / HTTP/1.1\r\nHost: godoxy-agent\r\n\r\n"))
require.NoError(t, err, "write http request")
r := bufio.NewReader(hc)
statusLine, err := r.ReadString('\n')
require.NoError(t, err, "read status line")
require.Contains(t, statusLine, "200", "expected 200")
// Stream client over the same port
client := NewTCPClient(t, baseAddr, dstAddr, certs)
defer client.Close()
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
msg := []byte("ping over mux")
_, err = client.Write(msg)
require.NoError(t, err, "write stream payload")
buf := make([]byte, len(msg))
_, err = io.ReadFull(client, buf)
require.NoError(t, err, "read stream payload")
require.Equal(t, msg, buf)
}

View File

@@ -0,0 +1,201 @@
package stream_test
import (
"crypto/tls"
"fmt"
"io"
"sync"
"testing"
"time"
"github.com/pion/dtls/v3"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/agent/pkg/agent/stream"
)
func TestTCPServer_FullFlow(t *testing.T) {
certs := genTestCerts(t)
dstAddr, closeDst := startTCPEcho(t)
defer closeDst()
srv := startTCPServer(t, certs)
client := NewTCPClient(t, srv.Addr.String(), dstAddr, certs)
defer client.Close()
// Ensure ALPN is negotiated as expected (required for multiplexing).
withState, ok := client.(interface{ ConnectionState() tls.ConnectionState })
require.True(t, ok, "tcp client should expose TLS connection state")
require.Equal(t, stream.StreamALPN, withState.ConnectionState().NegotiatedProtocol)
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
msg := []byte("ping over tcp")
_, err := client.Write(msg)
require.NoError(t, err, "write to client")
buf := make([]byte, len(msg))
_, err = io.ReadFull(client, buf)
require.NoError(t, err, "read from client")
require.Equal(t, string(msg), string(buf), "unexpected echo")
}
func TestTCPServer_ConcurrentConnections(t *testing.T) {
certs := genTestCerts(t)
dstAddr, closeDst := startTCPEcho(t)
defer closeDst()
srv := startTCPServer(t, certs)
const nClients = 25
errs := make(chan error, nClients)
var wg sync.WaitGroup
wg.Add(nClients)
for i := range nClients {
go func() {
defer wg.Done()
client := NewTCPClient(t, srv.Addr.String(), dstAddr, certs)
defer client.Close()
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
msg := fmt.Appendf(nil, "ping over tcp %d", i)
if _, err := client.Write(msg); err != nil {
errs <- fmt.Errorf("write to client: %w", err)
return
}
buf := make([]byte, len(msg))
if _, err := io.ReadFull(client, buf); err != nil {
errs <- fmt.Errorf("read from client: %w", err)
return
}
if string(msg) != string(buf) {
errs <- fmt.Errorf("unexpected echo: got=%q want=%q", string(buf), string(msg))
return
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
require.NoError(t, err)
}
}
func TestUDPServer_RejectInvalidClient(t *testing.T) {
certs := genTestCerts(t)
// Generate a self-signed client cert that is NOT signed by the CA
_, _, invalidClientPEM, err := agent.NewAgent()
require.NoError(t, err, "generate invalid client certs")
invalidClientCert, err := invalidClientPEM.ToTLSCert()
require.NoError(t, err, "parse invalid client cert")
dstAddr, closeDst := startUDPEcho(t)
defer closeDst()
srv := startUDPServer(t, certs)
// Try to connect with a client cert from a different CA
_, err = stream.NewUDPClient(srv.Addr.String(), dstAddr, certs.CaCert, invalidClientCert)
require.Error(t, err, "expected error when connecting with client cert from different CA")
var handshakeErr *dtls.HandshakeError
require.ErrorAs(t, err, &handshakeErr, "expected handshake error")
}
func TestUDPServer_RejectClientWithoutCert(t *testing.T) {
certs := genTestCerts(t)
dstAddr, closeDst := startUDPEcho(t)
defer closeDst()
srv := startUDPServer(t, certs)
time.Sleep(time.Second)
// Try to connect without any client certificate
// Create a TLS cert without a private key to simulate no client cert
emptyCert := &tls.Certificate{}
_, err := stream.NewUDPClient(srv.Addr.String(), dstAddr, certs.CaCert, emptyCert)
require.Error(t, err, "expected error when connecting without client cert")
require.ErrorContains(t, err, "no certificate provided", "expected no cert error")
}
func TestUDPServer_FullFlow(t *testing.T) {
certs := genTestCerts(t)
dstAddr, closeDst := startUDPEcho(t)
defer closeDst()
srv := startUDPServer(t, certs)
client := NewUDPClient(t, srv.Addr.String(), dstAddr, certs)
defer client.Close()
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
msg := []byte("ping over udp")
_, err := client.Write(msg)
require.NoError(t, err, "write to client")
buf := make([]byte, 2048)
n, err := client.Read(buf)
require.NoError(t, err, "read from client")
require.Equal(t, string(msg), string(buf[:n]), "unexpected echo")
}
func TestUDPServer_ConcurrentConnections(t *testing.T) {
certs := genTestCerts(t)
dstAddr, closeDst := startUDPEcho(t)
defer closeDst()
srv := startUDPServer(t, certs)
const nClients = 25
errs := make(chan error, nClients)
var wg sync.WaitGroup
wg.Add(nClients)
for i := range nClients {
go func() {
defer wg.Done()
client := NewUDPClient(t, srv.Addr.String(), dstAddr, certs)
defer client.Close()
_ = client.SetDeadline(time.Now().Add(5 * time.Second))
msg := fmt.Appendf(nil, "ping over udp %d", i)
if _, err := client.Write(msg); err != nil {
errs <- fmt.Errorf("write to client: %w", err)
return
}
buf := make([]byte, 2048)
n, err := client.Read(buf)
if err != nil {
errs <- fmt.Errorf("read from client: %w", err)
return
}
if string(msg) != string(buf[:n]) {
errs <- fmt.Errorf("unexpected echo: got=%q want=%q", string(buf[:n]), string(msg))
return
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
require.NoError(t, err)
}
}

View File

@@ -0,0 +1,177 @@
package stream_test
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"io"
"net"
"testing"
"time"
"github.com/pion/transport/v3/udp"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/agent/pkg/agent/stream"
)
// CertBundle holds all certificates needed for testing.
type CertBundle struct {
CaCert *x509.Certificate
SrvCert *tls.Certificate
ClientCert *tls.Certificate
}
// genTestCerts generates certificates for testing and returns them as a CertBundle.
func genTestCerts(t *testing.T) CertBundle {
t.Helper()
caPEM, srvPEM, clientPEM, err := agent.NewAgent()
require.NoError(t, err, "generate agent certs")
caCert, err := caPEM.ToTLSCert()
require.NoError(t, err, "parse CA cert")
srvCert, err := srvPEM.ToTLSCert()
require.NoError(t, err, "parse server cert")
clientCert, err := clientPEM.ToTLSCert()
require.NoError(t, err, "parse client cert")
return CertBundle{
CaCert: caCert.Leaf,
SrvCert: srvCert,
ClientCert: clientCert,
}
}
// startTCPEcho starts a TCP echo server and returns its address and close function.
func startTCPEcho(t *testing.T) (addr string, closeFn func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err, "listen tcp")
done := make(chan struct{})
go func() {
defer close(done)
for {
c, err := ln.Accept()
if err != nil {
return
}
go func(conn net.Conn) {
defer conn.Close()
_, _ = io.Copy(conn, conn)
}(c)
}
}()
return ln.Addr().String(), func() {
_ = ln.Close()
<-done
}
}
// startUDPEcho starts a UDP echo server and returns its address and close function.
func startUDPEcho(t *testing.T) (addr string, closeFn func()) {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
require.NoError(t, err, "listen udp")
uc := pc.(*net.UDPConn)
done := make(chan struct{})
go func() {
defer close(done)
buf := make([]byte, 65535)
for {
n, raddr, err := uc.ReadFromUDP(buf)
if err != nil {
return
}
_, _ = uc.WriteToUDP(buf[:n], raddr)
}
}()
return uc.LocalAddr().String(), func() {
_ = uc.Close()
<-done
}
}
// TestServer wraps a server with its startup goroutine for cleanup.
type TestServer struct {
Server interface{ Close() error }
Addr net.Addr
}
// startTCPServer starts a TCP server and returns a TestServer for cleanup.
func startTCPServer(t *testing.T, certs CertBundle) TestServer {
t.Helper()
tcpLn, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
require.NoError(t, err, "listen tcp")
ctx, cancel := context.WithCancel(t.Context())
srv := stream.NewTCPServer(ctx, tcpLn, certs.CaCert, certs.SrvCert)
errCh := make(chan error, 1)
go func() { errCh <- srv.Start() }()
t.Cleanup(func() {
cancel()
_ = srv.Close()
err := <-errCh
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, net.ErrClosed) {
t.Logf("tcp server exit: %v", err)
}
})
return TestServer{
Server: srv,
Addr: srv.Addr(),
}
}
// startUDPServer starts a UDP server and returns a TestServer for cleanup.
func startUDPServer(t *testing.T, certs CertBundle) TestServer {
t.Helper()
ctx, cancel := context.WithCancel(t.Context())
srv := stream.NewUDPServer(ctx, "udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}, certs.CaCert, certs.SrvCert)
errCh := make(chan error, 1)
go func() { errCh <- srv.Start() }()
time.Sleep(100 * time.Millisecond)
t.Cleanup(func() {
cancel()
_ = srv.Close()
err := <-errCh
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, net.ErrClosed) && !errors.Is(err, udp.ErrClosedListener) {
t.Logf("udp server exit: %v", err)
}
})
return TestServer{
Server: srv,
Addr: srv.Addr(),
}
}
// NewTCPClient creates a TCP client connected to the server with test certificates.
func NewTCPClient(t *testing.T, serverAddr, targetAddress string, certs CertBundle) net.Conn {
t.Helper()
client, err := stream.NewTCPClient(serverAddr, targetAddress, certs.CaCert, certs.ClientCert)
require.NoError(t, err, "create tcp client")
return client
}
// NewUDPClient creates a UDP client connected to the server with test certificates.
func NewUDPClient(t *testing.T, serverAddr, targetAddress string, certs CertBundle) net.Conn {
t.Helper()
client, err := stream.NewUDPClient(serverAddr, targetAddress, certs.CaCert, certs.ClientCert)
require.NoError(t, err, "create udp client")
return client
}

View File

@@ -0,0 +1,118 @@
package stream
import (
"crypto/tls"
"crypto/x509"
"net"
"time"
"github.com/pion/dtls/v3"
"github.com/yusing/godoxy/agent/pkg/agent/common"
)
type UDPClient struct {
conn net.Conn
}
// NewUDPClient creates a new UDP client for the agent.
//
// It will establish a DTLS connection and send a stream request header to the server.
//
// It returns an error if
// - the target address is invalid
// - the stream request header is invalid
// - the DTLS configuration is invalid
// - the DTLS connection fails
// - the stream request header is not sent
func NewUDPClient(serverAddr, targetAddress string, caCert *x509.Certificate, clientCert *tls.Certificate) (net.Conn, error) {
host, port, err := net.SplitHostPort(targetAddress)
if err != nil {
return nil, err
}
header, err := NewStreamRequestHeader(host, port)
if err != nil {
return nil, err
}
return newUDPClientWIthHeader(serverAddr, header, caCert, clientCert)
}
func newUDPClientWIthHeader(serverAddr string, header *StreamRequestHeader, caCert *x509.Certificate, clientCert *tls.Certificate) (net.Conn, error) {
// Setup DTLS configuration
caCertPool := x509.NewCertPool()
caCertPool.AddCert(caCert)
dtlsConfig := &dtls.Config{
Certificates: []tls.Certificate{*clientCert},
RootCAs: caCertPool,
InsecureSkipVerify: false,
ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
ServerName: common.CertsDNSName,
CipherSuites: dTLSCipherSuites,
}
raddr, err := net.ResolveUDPAddr("udp", serverAddr)
if err != nil {
return nil, err
}
// Establish DTLS connection
conn, err := dtls.Dial("udp", raddr, dtlsConfig)
if err != nil {
return nil, err
}
// Send the stream header once as a handshake.
if _, err := conn.Write(header.Bytes()); err != nil {
_ = conn.Close()
return nil, err
}
return &UDPClient{
conn: conn,
}, nil
}
func UDPHealthCheck(serverAddr string, caCert *x509.Certificate, clientCert *tls.Certificate) error {
header := NewStreamHealthCheckHeader()
conn, err := newUDPClientWIthHeader(serverAddr, header, caCert, clientCert)
if err != nil {
return err
}
conn.Close()
return nil
}
func (c *UDPClient) Read(p []byte) (n int, err error) {
return c.conn.Read(p)
}
func (c *UDPClient) Write(p []byte) (n int, err error) {
return c.conn.Write(p)
}
func (c *UDPClient) LocalAddr() net.Addr {
return c.conn.LocalAddr()
}
func (c *UDPClient) RemoteAddr() net.Addr {
return c.conn.RemoteAddr()
}
func (c *UDPClient) SetDeadline(t time.Time) error {
return c.conn.SetDeadline(t)
}
func (c *UDPClient) SetReadDeadline(t time.Time) error {
return c.conn.SetReadDeadline(t)
}
func (c *UDPClient) SetWriteDeadline(t time.Time) error {
return c.conn.SetWriteDeadline(t)
}
func (c *UDPClient) Close() error {
return c.conn.Close()
}

View File

@@ -0,0 +1,208 @@
package stream
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"io"
"net"
"time"
"github.com/pion/dtls/v3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
type UDPServer struct {
ctx context.Context
network string
laddr *net.UDPAddr
listener net.Listener
dtlsConfig *dtls.Config
}
func NewUDPServer(ctx context.Context, network string, laddr *net.UDPAddr, caCert *x509.Certificate, serverCert *tls.Certificate) *UDPServer {
caCertPool := x509.NewCertPool()
caCertPool.AddCert(caCert)
dtlsConfig := &dtls.Config{
Certificates: []tls.Certificate{*serverCert},
ClientCAs: caCertPool,
ClientAuth: dtls.RequireAndVerifyClientCert,
ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
CipherSuites: dTLSCipherSuites,
}
s := &UDPServer{
ctx: ctx,
network: network,
laddr: laddr,
dtlsConfig: dtlsConfig,
}
return s
}
func (s *UDPServer) Start() error {
listener, err := dtls.Listen(s.network, s.laddr, s.dtlsConfig)
if err != nil {
return err
}
s.listener = listener
context.AfterFunc(s.ctx, func() {
_ = s.listener.Close()
})
for {
conn, err := s.listener.Accept()
if err != nil {
// Expected error when context cancelled
if errors.Is(err, net.ErrClosed) && s.ctx.Err() != nil {
return s.ctx.Err()
}
return err
}
go s.handleDTLSConnection(conn)
}
}
func (s *UDPServer) Addr() net.Addr {
if s.listener != nil {
return s.listener.Addr()
}
return s.laddr
}
func (s *UDPServer) Close() error {
if s.listener != nil {
return s.listener.Close()
}
return nil
}
func (s *UDPServer) logger(clientConn net.Conn) *zerolog.Logger {
l := log.With().Str("protocol", "udp").
Str("addr", s.Addr().String()).
Str("remote", clientConn.RemoteAddr().String()).Logger()
return &l
}
func (s *UDPServer) loggerWithDst(clientConn net.Conn, dstConn *net.UDPConn) *zerolog.Logger {
l := log.With().Str("protocol", "udp").
Str("addr", s.Addr().String()).
Str("remote", clientConn.RemoteAddr().String()).
Str("dst", dstConn.RemoteAddr().String()).Logger()
return &l
}
func (s *UDPServer) handleDTLSConnection(clientConn net.Conn) {
defer clientConn.Close()
// Read the stream header once as a handshake.
var headerBuf [headerSize]byte
_ = clientConn.SetReadDeadline(time.Now().Add(dialTimeout))
if _, err := io.ReadFull(clientConn, headerBuf[:]); err != nil {
s.logger(clientConn).Err(err).Msg("failed to read stream header")
return
}
_ = clientConn.SetReadDeadline(time.Time{})
header := ToHeader(&headerBuf)
if !header.Validate() {
s.logger(clientConn).Error().Bytes("header", headerBuf[:]).Msg("invalid stream header received")
return
}
// Health check probe: close connection
if header.ShouldCloseImmediately() {
s.logger(clientConn).Info().Msg("Health check received")
return
}
host, port := header.GetHostPort()
dstConn, err := s.createDestConnection(host, port)
if err != nil {
s.logger(clientConn).Err(err).Msg("failed to get or create destination connection")
return
}
defer dstConn.Close()
go s.forwardFromDestination(dstConn, clientConn)
buf := sizedPool.GetSized(65535)
defer sizedPool.Put(buf)
for {
select {
case <-s.ctx.Done():
return
default:
n, err := clientConn.Read(buf)
// Per net.Conn contract, Read may return (n > 0, err == io.EOF).
// Always forward any bytes we got before acting on the error.
if n > 0 {
if _, werr := dstConn.Write(buf[:n]); werr != nil {
s.logger(clientConn).Err(werr).Msgf("failed to write %d bytes to destination", n)
return
}
}
if err != nil {
// Expected shutdown paths.
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return
}
s.logger(clientConn).Err(err).Msg("failed to read from client")
return
}
}
}
}
func (s *UDPServer) createDestConnection(host, port string) (*net.UDPConn, error) {
addr := net.JoinHostPort(host, port)
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return nil, err
}
dstConn, err := net.DialUDP("udp", nil, udpAddr)
if err != nil {
return nil, err
}
return dstConn, nil
}
func (s *UDPServer) forwardFromDestination(dstConn *net.UDPConn, clientConn net.Conn) {
buffer := sizedPool.GetSized(65535)
defer sizedPool.Put(buffer)
for {
select {
case <-s.ctx.Done():
return
default:
_ = dstConn.SetReadDeadline(time.Now().Add(readDeadline))
n, err := dstConn.Read(buffer)
if err != nil {
// The destination socket can be closed when the client disconnects (e.g. during
// the stream support probe in AgentConfig.StartWithCerts). Treat that as a
// normal exit and avoid noisy logs.
if errors.Is(err, net.ErrClosed) {
return
}
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
s.loggerWithDst(clientConn, dstConn).Err(err).Msg("failed to read from destination")
return
}
if _, err := clientConn.Write(buffer[:n]); err != nil {
s.loggerWithDst(clientConn, dstConn).Err(err).Msgf("failed to write %d bytes to client", n)
return
}
}
}
}

View File

@@ -1,44 +0,0 @@
services:
agent:
image: "{{.Image}}"
container_name: godoxy-agent
restart: always
network_mode: host # do not change this
environment:
AGENT_NAME: "{{.Name}}"
AGENT_PORT: "{{.Port}}"
AGENT_CA_CERT: "{{.CACert}}"
AGENT_SSL_CERT: "{{.SSLCert}}"
# use agent as a docker socket proxy: [host]:port
# set LISTEN_ADDR to enable (e.g. 127.0.0.1:2375)
LISTEN_ADDR:
POST: false
ALLOW_RESTARTS: false
ALLOW_START: false
ALLOW_STOP: false
AUTH: false
BUILD: false
COMMIT: false
CONFIGS: false
CONTAINERS: false
DISTRIBUTION: false
EVENTS: true
EXEC: false
GRPC: false
IMAGES: false
INFO: false
NETWORKS: false
NODES: false
PING: true
PLUGINS: false
SECRETS: false
SERVICES: false
SESSION: false
SWARM: false
SYSTEM: false
TASKS: false
VERSION: true
VOLUMES: false
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/app/data

View File

@@ -5,7 +5,8 @@ services:
restart: always
{{ if eq .ContainerRuntime "podman" -}}
ports:
- "{{.Port}}:{{.Port}}"
- "{{.Port}}:{{.Port}}/tcp"
- "{{.Port}}:{{.Port}}/udp"
{{ else -}}
network_mode: host # do not change this
{{ end -}}

View File

@@ -1,9 +1,9 @@
package handler
import (
"fmt"
"net/http"
"github.com/bytedance/sonic"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/yusing/godoxy/agent/pkg/agent"
@@ -44,14 +44,14 @@ func NewAgentHandler() http.Handler {
}
mux.HandleFunc(agent.EndpointProxyHTTP+"/{path...}", ProxyHTTP)
mux.HandleEndpoint("GET", agent.EndpointVersion, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, version.Get())
})
mux.HandleEndpoint("GET", agent.EndpointName, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, env.AgentName)
})
mux.HandleEndpoint("GET", agent.EndpointRuntime, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, env.Runtime)
mux.HandleFunc(agent.EndpointInfo, func(w http.ResponseWriter, r *http.Request) {
agentInfo := agent.AgentInfo{
Version: version.Get(),
Name: env.AgentName,
Runtime: env.Runtime,
}
w.Header().Set("Content-Type", "application/json")
sonic.ConfigDefault.NewEncoder(w).Encode(agentInfo)
})
mux.HandleEndpoint("GET", agent.EndpointHealth, CheckHealth)
mux.HandleEndpoint("GET", agent.EndpointSystemInfo, metricsHandler.ServeHTTP)

View File

@@ -1,43 +0,0 @@
package server
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/env"
"github.com/yusing/godoxy/agent/pkg/handler"
"github.com/yusing/goutils/server"
"github.com/yusing/goutils/task"
)
type Options struct {
CACert, ServerCert *tls.Certificate
Port int
}
func StartAgentServer(parent task.Parent, opt Options) {
caCertPool := x509.NewCertPool()
caCertPool.AddCert(opt.CACert.Leaf)
// Configure TLS
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{*opt.ServerCert},
ClientCAs: caCertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
}
if env.AgentSkipClientCertCheck {
tlsConfig.ClientAuth = tls.NoClientCert
}
agentServer := &http.Server{
Addr: fmt.Sprintf(":%d", opt.Port),
Handler: handler.NewAgentHandler(),
TLSConfig: tlsConfig,
}
server.Start(parent.Subtask("agent-server", false), agentServer, server.WithLogger(&log.Logger))
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/config"
"github.com/yusing/godoxy/internal/dnsproviders"
"github.com/yusing/godoxy/internal/homepage"
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list"
"github.com/yusing/godoxy/internal/logging"
"github.com/yusing/godoxy/internal/logging/memlogger"
"github.com/yusing/godoxy/internal/metrics/systeminfo"
@@ -39,7 +39,7 @@ func main() {
log.Trace().Msg("trace enabled")
parallel(
dnsproviders.InitProviders,
homepage.InitIconListCache,
iconlist.InitCache,
systeminfo.Poller.Start,
middleware.LoadComposeFiles,
)

29
go.mod
View File

@@ -18,7 +18,7 @@ require (
github.com/coreos/go-oidc/v3 v3.17.0 // oidc authentication
github.com/fsnotify/fsnotify v1.9.0 // file watcher
github.com/gin-gonic/gin v1.11.0 // api server
github.com/go-acme/lego/v4 v4.30.1 // acme client
github.com/go-acme/lego/v4 v4.31.0 // acme client
github.com/go-playground/validator/v10 v10.30.1 // validator
github.com/gobwas/glob v0.2.3 // glob matcher for route rules
github.com/gorilla/websocket v1.5.3 // websocket for API and agent
@@ -39,9 +39,9 @@ require (
github.com/bytedance/gopkg v0.1.3 // xxhash64 for fast hash
github.com/bytedance/sonic v1.14.2 // fast json parsing
github.com/docker/cli v29.1.3+incompatible // needs docker/cli/cli/connhelper connection helper for docker client
github.com/goccy/go-yaml v1.19.1 // yaml parsing for different config files
github.com/goccy/go-yaml v1.19.2 // yaml parsing for different config files
github.com/golang-jwt/jwt/v5 v5.3.0 // jwt authentication
github.com/luthermonson/go-proxmox v0.3.1 // proxmox API client
github.com/luthermonson/go-proxmox v0.3.2 // proxmox API client
github.com/moby/moby/api v1.52.0 // docker API
github.com/moby/moby/client v0.2.1 // docker client
github.com/oschwald/maxminddb-golang v1.13.1 // maxminddb for geoip database
@@ -49,15 +49,15 @@ require (
github.com/shirou/gopsutil/v4 v4.25.12 // system information
github.com/spf13/afero v1.15.0 // afero for file system operations
github.com/stretchr/testify v1.11.1 // testing framework
github.com/valyala/fasthttp v1.68.0 // fast http for health check
github.com/valyala/fasthttp v1.69.0 // fast http for health check
github.com/yusing/ds v0.3.1 // data structures and algorithms
github.com/yusing/godoxy/agent v0.0.0-20260104140148-1c2515cb298d
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260104140148-1c2515cb298d
github.com/yusing/godoxy/agent v0.0.0-20260109022755-4275cdae3854
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260109022755-4275cdae3854
github.com/yusing/gointernals v0.1.16
github.com/yusing/goutils v0.7.0
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260103043911-785deb23bd64
github.com/yusing/goutils/http/websocket v0.0.0-20260103043911-785deb23bd64
github.com/yusing/goutils/server v0.0.0-20260103043911-785deb23bd64
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260109021609-78fda75d1e58
github.com/yusing/goutils/http/websocket v0.0.0-20260109021609-78fda75d1e58
github.com/yusing/goutils/server v0.0.0-20260109021609-78fda75d1e58
)
require (
@@ -92,7 +92,7 @@ require (
github.com/gofrs/flock v0.13.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.9 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
@@ -132,10 +132,10 @@ require (
go.uber.org/atomic v1.11.0
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/api v0.258.0 // indirect
google.golang.org/api v0.259.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
@@ -166,12 +166,15 @@ require (
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/linode/linodego v1.63.0 // indirect
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.105.2 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pion/dtls/v3 v3.0.10 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect

38
go.sum
View File

@@ -100,8 +100,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-acme/lego/v4 v4.30.1 h1:tmb6U0lvy8Mc3lQbqKwTat7oAhE8FUYNJ3D0gSg6pJU=
github.com/go-acme/lego/v4 v4.30.1/go.mod h1:V7m/Ip+EeFkjOe028+zeH+SwWtESxw1LHelwMIfAjm4=
github.com/go-acme/lego/v4 v4.31.0 h1:gd4oUYdfs83PR1/SflkNdit9xY1iul2I4EystnU8NXM=
github.com/go-acme/lego/v4 v4.31.0/go.mod h1:m6zcfX/zcbMYDa8s6AnCMnoORWNP8Epnei+6NBCTUGs=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -132,8 +132,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
@@ -151,8 +151,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/enterprise-certificate-proxy v0.3.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU=
github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -191,14 +191,14 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/linode/linodego v1.63.0 h1:MdjizfXNJDVJU6ggoJmMO5O9h4KGPGivNX0fzrAnstk=
github.com/linode/linodego v1.63.0/go.mod h1:GoiwLVuLdBQcAebxAVKVL3mMYUgJZR/puOUSla04xBE=
github.com/linode/linodego v1.64.0 h1:If6pULIwHuQytgogtpQaBdVLX7z2TTHUF5u1tj2TPiY=
github.com/linode/linodego v1.64.0/go.mod h1:GoiwLVuLdBQcAebxAVKVL3mMYUgJZR/puOUSla04xBE=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/luthermonson/go-proxmox v0.3.1 h1:h64s4/zIEQ06TBo0phFKcckV441YpvUPgLfRAptYsjY=
github.com/luthermonson/go-proxmox v0.3.1/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/luthermonson/go-proxmox v0.3.2 h1:/zUg6FCl9cAABx0xU3OIgtDtClY0gVXxOCsrceDNylc=
github.com/luthermonson/go-proxmox v0.3.2/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -247,6 +247,12 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
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.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -313,8 +319,8 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
github.com/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCxJw=
@@ -410,8 +416,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -445,8 +451,8 @@ golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc
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.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
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=

Submodule goutils updated: 78fda75d1e...326c1f1eb3

View File

@@ -811,7 +811,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/homepage.FetchResult"
"$ref": "#/definitions/iconfetch.Result"
}
}
},
@@ -1698,7 +1698,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/homepage.IconMetaSearch"
"$ref": "#/definitions/iconlist.IconMetaSearch"
}
}
},
@@ -2356,6 +2356,16 @@
"x-nullable": false,
"x-omitempty": false
},
"supports_tcp_stream": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"supports_udp_stream": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"version": {
"type": "string",
"x-nullable": false,
@@ -2439,7 +2449,7 @@
"type": "object",
"properties": {
"agent": {
"$ref": "#/definitions/Agent",
"$ref": "#/definitions/agentpool.Agent",
"x-nullable": false,
"x-omitempty": false
},
@@ -4325,8 +4335,7 @@
"items": {
"$ref": "#/definitions/rules.Rule"
},
"x-nullable": false,
"x-omitempty": false
"x-nullable": true
},
"scheme": {
"type": "string",
@@ -4909,6 +4918,43 @@
"x-nullable": false,
"x-omitempty": false
},
"agentpool.Agent": {
"type": "object",
"properties": {
"addr": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"name": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"runtime": {
"$ref": "#/definitions/agent.ContainerRuntime",
"x-nullable": false,
"x-omitempty": false
},
"supports_tcp_stream": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"supports_udp_stream": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"version": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"auth.UserPassAuthCallbackRequest": {
"type": "object",
"properties": {
@@ -5155,7 +5201,7 @@
"x-nullable": false,
"x-omitempty": false
},
"homepage.FetchResult": {
"iconfetch.Result": {
"type": "object",
"properties": {
"icon": {
@@ -5176,7 +5222,7 @@
"x-nullable": false,
"x-omitempty": false
},
"homepage.IconMetaSearch": {
"iconlist.IconMetaSearch": {
"type": "object",
"properties": {
"Dark": {
@@ -5205,7 +5251,7 @@
"x-omitempty": false
},
"Source": {
"$ref": "#/definitions/homepage.IconSource",
"$ref": "#/definitions/icons.Source",
"x-nullable": false,
"x-omitempty": false
},
@@ -5218,7 +5264,7 @@
"x-nullable": false,
"x-omitempty": false
},
"homepage.IconSource": {
"icons.Source": {
"type": "string",
"enum": [
"https://",
@@ -5227,10 +5273,10 @@
"@selfhst"
],
"x-enum-varnames": [
"IconSourceAbsolute",
"IconSourceRelative",
"IconSourceWalkXCode",
"IconSourceSelfhSt"
"SourceAbsolute",
"SourceRelative",
"SourceWalkXCode",
"SourceSelfhSt"
],
"x-nullable": false,
"x-omitempty": false
@@ -5468,8 +5514,7 @@
"items": {
"$ref": "#/definitions/rules.Rule"
},
"x-nullable": false,
"x-omitempty": false
"x-nullable": true
},
"scheme": {
"type": "string",

View File

@@ -8,6 +8,10 @@ definitions:
type: string
runtime:
$ref: '#/definitions/agent.ContainerRuntime'
supports_tcp_stream:
type: boolean
supports_udp_stream:
type: boolean
version:
type: string
type: object
@@ -48,7 +52,7 @@ definitions:
Container:
properties:
agent:
$ref: '#/definitions/Agent'
$ref: '#/definitions/agentpool.Agent'
aliases:
items:
type: string
@@ -955,6 +959,7 @@ definitions:
items:
$ref: '#/definitions/rules.Rule'
type: array
x-nullable: true
scheme:
enum:
- http
@@ -1236,6 +1241,21 @@ definitions:
x-enum-varnames:
- ContainerRuntimeDocker
- ContainerRuntimePodman
agentpool.Agent:
properties:
addr:
type: string
name:
type: string
runtime:
$ref: '#/definitions/agent.ContainerRuntime'
supports_tcp_stream:
type: boolean
supports_udp_stream:
type: boolean
version:
type: string
type: object
auth.UserPassAuthCallbackRequest:
properties:
password:
@@ -1409,7 +1429,7 @@ definitions:
required:
- id
type: object
homepage.FetchResult:
iconfetch.Result:
properties:
icon:
items:
@@ -1419,7 +1439,7 @@ definitions:
statusCode:
type: integer
type: object
homepage.IconMetaSearch:
iconlist.IconMetaSearch:
properties:
Dark:
type: boolean
@@ -1432,11 +1452,11 @@ definitions:
SVG:
type: boolean
Source:
$ref: '#/definitions/homepage.IconSource'
$ref: '#/definitions/icons.Source'
WebP:
type: boolean
type: object
homepage.IconSource:
icons.Source:
enum:
- https://
- '@target'
@@ -1444,10 +1464,10 @@ definitions:
- '@selfhst'
type: string
x-enum-varnames:
- IconSourceAbsolute
- IconSourceRelative
- IconSourceWalkXCode
- IconSourceSelfhSt
- SourceAbsolute
- SourceRelative
- SourceWalkXCode
- SourceSelfhSt
mem.VirtualMemoryStat:
properties:
available:
@@ -1575,6 +1595,7 @@ definitions:
items:
$ref: '#/definitions/rules.Rule'
type: array
x-nullable: true
scheme:
enum:
- http
@@ -2210,7 +2231,7 @@ paths:
description: OK
schema:
items:
$ref: '#/definitions/homepage.FetchResult'
$ref: '#/definitions/iconfetch.Result'
type: array
"400":
description: 'Bad Request: alias is empty or route is not HTTPRoute'
@@ -2792,7 +2813,7 @@ paths:
description: OK
schema:
items:
$ref: '#/definitions/homepage.IconMetaSearch'
$ref: '#/definitions/iconlist.IconMetaSearch'
type: array
"400":
description: Bad Request

View File

@@ -5,7 +5,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/homepage/icons"
iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch"
"github.com/yusing/godoxy/internal/route/routes"
apitypes "github.com/yusing/goutils/apitypes"
@@ -13,9 +14,9 @@ import (
)
type GetFavIconRequest struct {
URL string `form:"url" binding:"required_without=Alias"`
Alias string `form:"alias" binding:"required_without=URL"`
Variant homepage.IconVariant `form:"variant" binding:"omitempty,oneof=light dark"`
URL string `form:"url" binding:"required_without=Alias"`
Alias string `form:"alias" binding:"required_without=URL"`
Variant icons.Variant `form:"variant" binding:"omitempty,oneof=light dark"`
} // @name GetFavIconRequest
// @x-id "favicon"
@@ -27,7 +28,7 @@ type GetFavIconRequest struct {
// @Produce image/svg+xml,image/x-icon,image/png,image/webp
// @Param url query string false "URL of the route"
// @Param alias query string false "Alias of the route"
// @Success 200 {array} homepage.FetchResult
// @Success 200 {array} iconfetch.Result
// @Failure 400 {object} apitypes.ErrorResponse "Bad Request: alias is empty or route is not HTTPRoute"
// @Failure 403 {object} apitypes.ErrorResponse "Forbidden: unauthorized"
// @Failure 404 {object} apitypes.ErrorResponse "Not Found: route or icon not found"
@@ -42,18 +43,18 @@ func FavIcon(c *gin.Context) {
// try with url
if request.URL != "" {
var iconURL homepage.IconURL
var iconURL icons.URL
if err := iconURL.Parse(request.URL); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid url", err))
return
}
icon := &iconURL
if request.Variant != homepage.IconVariantNone {
if request.Variant != icons.VariantNone {
icon = icon.WithVariant(request.Variant)
}
fetchResult, err := homepage.FetchFavIconFromURL(c.Request.Context(), icon)
fetchResult, err := iconfetch.FetchFavIconFromURL(c.Request.Context(), icon)
if err != nil {
homepage.GinFetchError(c, fetchResult.StatusCode, err)
iconfetch.GinError(c, fetchResult.StatusCode, err)
return
}
c.Data(fetchResult.StatusCode, fetchResult.ContentType(), fetchResult.Icon)
@@ -63,40 +64,40 @@ func FavIcon(c *gin.Context) {
// try with alias
result, err := GetFavIconFromAlias(c.Request.Context(), request.Alias, request.Variant)
if err != nil {
homepage.GinFetchError(c, result.StatusCode, err)
iconfetch.GinError(c, result.StatusCode, err)
return
}
c.Data(result.StatusCode, result.ContentType(), result.Icon)
}
//go:linkname GetFavIconFromAlias v1.GetFavIconFromAlias
func GetFavIconFromAlias(ctx context.Context, alias string, variant homepage.IconVariant) (homepage.FetchResult, error) {
func GetFavIconFromAlias(ctx context.Context, alias string, variant icons.Variant) (iconfetch.Result, error) {
// try with route.Icon
r, ok := routes.HTTP.Get(alias)
if !ok {
return homepage.FetchResultWithErrorf(http.StatusNotFound, "route not found")
return iconfetch.FetchResultWithErrorf(http.StatusNotFound, "route not found")
}
var (
result homepage.FetchResult
result iconfetch.Result
err error
)
hp := r.HomepageItem()
if hp.Icon != nil {
if hp.Icon.IconSource == homepage.IconSourceRelative {
result, err = homepage.FindIcon(ctx, r, *hp.Icon.FullURL, variant)
} else if variant != homepage.IconVariantNone {
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon.WithVariant(variant))
if hp.Icon.Source == icons.SourceRelative {
result, err = iconfetch.FindIcon(ctx, r, *hp.Icon.FullURL, variant)
} else if variant != icons.VariantNone {
result, err = iconfetch.FetchFavIconFromURL(ctx, hp.Icon.WithVariant(variant))
if err != nil {
// fallback to no variant
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon.WithVariant(homepage.IconVariantNone))
result, err = iconfetch.FetchFavIconFromURL(ctx, hp.Icon.WithVariant(icons.VariantNone))
}
} else {
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon)
result, err = iconfetch.FetchFavIconFromURL(ctx, hp.Icon)
}
} else {
// try extract from "link[rel=icon]"
result, err = homepage.FindIcon(ctx, r, "/", variant)
result, err = iconfetch.FindIcon(ctx, r, "/", variant)
}
if result.StatusCode == 0 {
result.StatusCode = http.StatusOK

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/homepage"
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list"
apitypes "github.com/yusing/goutils/apitypes"
)
@@ -22,7 +22,7 @@ type ListIconsRequest struct {
// @Produce json
// @Param limit query int false "Limit"
// @Param keyword query string false "Keyword"
// @Success 200 {array} homepage.IconMetaSearch
// @Success 200 {array} iconlist.IconMetaSearch
// @Failure 400 {object} apitypes.ErrorResponse
// @Failure 403 {object} apitypes.ErrorResponse
// @Router /icons [get]
@@ -32,6 +32,6 @@ func Icons(c *gin.Context) {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
return
}
icons := homepage.SearchIcons(request.Keyword, request.Limit)
icons := iconlist.SearchIcons(request.Keyword, request.Limit)
c.JSON(http.StatusOK, icons)
}

View File

@@ -5,8 +5,8 @@ go 1.25.5
replace github.com/yusing/godoxy => ../..
require (
github.com/go-acme/lego/v4 v4.30.1
github.com/yusing/godoxy v0.23.0
github.com/go-acme/lego/v4 v4.31.0
github.com/yusing/godoxy v0.23.1
)
require (
@@ -42,13 +42,13 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.19.1 // 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/google/go-querystring v1.2.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.9 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/gotify/server/v2 v2.8.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@@ -57,7 +57,7 @@ require (
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/linode/linodego v1.63.0 // indirect
github.com/linode/linodego v1.64.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/maxatome/go-testdeep v1.14.0 // indirect
@@ -95,10 +95,10 @@ require (
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/api v0.258.0 // indirect
google.golang.org/api v0.259.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect

View File

@@ -62,8 +62,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-acme/lego/v4 v4.30.1 h1:tmb6U0lvy8Mc3lQbqKwTat7oAhE8FUYNJ3D0gSg6pJU=
github.com/go-acme/lego/v4 v4.30.1/go.mod h1:V7m/Ip+EeFkjOe028+zeH+SwWtESxw1LHelwMIfAjm4=
github.com/go-acme/lego/v4 v4.31.0 h1:gd4oUYdfs83PR1/SflkNdit9xY1iul2I4EystnU8NXM=
github.com/go-acme/lego/v4 v4.31.0/go.mod h1:m6zcfX/zcbMYDa8s6AnCMnoORWNP8Epnei+6NBCTUGs=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -85,8 +85,8 @@ github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6x
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
@@ -103,8 +103,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/enterprise-certificate-proxy v0.3.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU=
github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gotify/server/v2 v2.8.0 h1:E3UDDn/3rFZi1sjZfbuhXNnxJP3ACZhdcw/iySegPRA=
@@ -131,8 +131,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/linode/linodego v1.63.0 h1:MdjizfXNJDVJU6ggoJmMO5O9h4KGPGivNX0fzrAnstk=
github.com/linode/linodego v1.63.0/go.mod h1:GoiwLVuLdBQcAebxAVKVL3mMYUgJZR/puOUSla04xBE=
github.com/linode/linodego v1.64.0 h1:If6pULIwHuQytgogtpQaBdVLX7z2TTHUF5u1tj2TPiY=
github.com/linode/linodego v1.64.0/go.mod h1:GoiwLVuLdBQcAebxAVKVL3mMYUgJZR/puOUSla04xBE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -237,8 +237,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
@@ -249,8 +249,8 @@ golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
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.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
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=

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/yusing/ds/ordered"
"github.com/yusing/godoxy/internal/homepage/icons"
"github.com/yusing/godoxy/internal/homepage/widgets"
"github.com/yusing/godoxy/internal/serialization"
strutils "github.com/yusing/goutils/strings"
@@ -22,13 +23,13 @@ type (
} // @name HomepageCategory
ItemConfig struct {
Show bool `json:"show"`
Name string `json:"name"` // display name
Icon *IconURL `json:"icon" swaggertype:"string"`
Category string `json:"category" validate:"omitempty"`
Description string `json:"description" aliases:"desc"`
URL string `json:"url,omitempty"`
Favorite bool `json:"favorite"`
Show bool `json:"show"`
Name string `json:"name"` // display name
Icon *icons.URL `json:"icon" swaggertype:"string"`
Category string `json:"category" validate:"omitempty"`
Description string `json:"description" aliases:"desc"`
URL string `json:"url,omitempty"`
Favorite bool `json:"favorite"`
WidgetConfig *widgets.Config `json:"widget_config,omitempty" aliases:"widget" extensions:"x-nullable"`
} // @name HomepageItemConfig

View File

@@ -4,19 +4,24 @@ import (
"testing"
. "github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/homepage/icons"
expect "github.com/yusing/goutils/testing"
)
func strPtr(s string) *string {
return &s
}
func TestOverrideItem(t *testing.T) {
a := &Item{
Alias: "foo",
ItemConfig: ItemConfig{
Show: false,
Name: "Foo",
Icon: &IconURL{
FullURL: strPtr("/favicon.ico"),
IconSource: IconSourceRelative,
Icon: &icons.URL{
FullURL: strPtr("/favicon.ico"),
Source: icons.SourceRelative,
},
Category: "App",
},
@@ -25,9 +30,9 @@ func TestOverrideItem(t *testing.T) {
Show: true,
Name: "Bar",
Category: "Test",
Icon: &IconURL{
FullURL: strPtr("@walkxcode/example.png"),
IconSource: IconSourceWalkXCode,
Icon: &icons.URL{
FullURL: strPtr("@walkxcode/example.png"),
Source: icons.SourceWalkXCode,
},
}
overrides := GetOverrideConfig()

View File

@@ -1,4 +1,4 @@
package homepage
package iconfetch
import (
"bufio"

View File

@@ -1,4 +1,4 @@
package homepage
package iconfetch
import (
"bytes"
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"slices"
@@ -15,6 +16,7 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
"github.com/vincent-petithory/dataurl"
"github.com/yusing/godoxy/internal/homepage/icons"
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/cache"
@@ -22,22 +24,22 @@ import (
strutils "github.com/yusing/goutils/strings"
)
type FetchResult struct {
type Result struct {
Icon []byte
StatusCode int
contentType string
}
func FetchResultWithErrorf(statusCode int, msgFmt string, args ...any) (FetchResult, error) {
return FetchResult{StatusCode: statusCode}, fmt.Errorf(msgFmt, args...)
func FetchResultWithErrorf(statusCode int, msgFmt string, args ...any) (Result, error) {
return Result{StatusCode: statusCode}, fmt.Errorf(msgFmt, args...)
}
func FetchResultOK(icon []byte, contentType string) (FetchResult, error) {
return FetchResult{Icon: icon, contentType: contentType}, nil
func FetchResultOK(icon []byte, contentType string) (Result, error) {
return Result{Icon: icon, contentType: contentType}, nil
}
func GinFetchError(c *gin.Context, statusCode int, err error) {
func GinError(c *gin.Context, statusCode int, err error) {
if statusCode == 0 {
statusCode = http.StatusInternalServerError
}
@@ -50,7 +52,7 @@ func GinFetchError(c *gin.Context, statusCode int, err error) {
const faviconFetchTimeout = 3 * time.Second
func (res *FetchResult) ContentType() string {
func (res *Result) ContentType() string {
if res.contentType == "" {
if bytes.HasPrefix(res.Icon, []byte("<svg")) || bytes.HasPrefix(res.Icon, []byte("<?xml")) {
return "image/svg+xml"
@@ -62,19 +64,19 @@ func (res *FetchResult) ContentType() string {
const maxRedirectDepth = 5
func FetchFavIconFromURL(ctx context.Context, iconURL *IconURL) (FetchResult, error) {
switch iconURL.IconSource {
case IconSourceAbsolute:
func FetchFavIconFromURL(ctx context.Context, iconURL *icons.URL) (Result, error) {
switch iconURL.Source {
case icons.SourceAbsolute:
return FetchIconAbsolute(ctx, iconURL.URL())
case IconSourceRelative:
case icons.SourceRelative:
return FetchResultWithErrorf(http.StatusBadRequest, "unexpected relative icon")
case IconSourceWalkXCode, IconSourceSelfhSt:
case icons.SourceWalkXCode, icons.SourceSelfhSt:
return fetchKnownIcon(ctx, iconURL)
}
return FetchResultWithErrorf(http.StatusBadRequest, "invalid icon source")
}
var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (FetchResult, error) {
var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (Result, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err)
@@ -103,7 +105,7 @@ var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (
return FetchResultWithErrorf(http.StatusNotFound, "empty icon")
}
res := FetchResult{Icon: icon}
res := Result{Icon: icon}
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
res.contentType = contentType
}
@@ -122,22 +124,22 @@ func sanitizeName(name string) string {
return strings.ToLower(nameSanitizer.Replace(name))
}
func fetchKnownIcon(ctx context.Context, url *IconURL) (FetchResult, error) {
func fetchKnownIcon(ctx context.Context, url *icons.URL) (Result, error) {
// if icon isn't in the list, no need to fetch
if !url.HasIcon() {
return FetchResult{StatusCode: http.StatusNotFound}, errors.New("no such icon")
return Result{StatusCode: http.StatusNotFound}, errors.New("no such icon")
}
return FetchIconAbsolute(ctx, url.URL())
}
func fetchIcon(ctx context.Context, filename string) (FetchResult, error) {
func fetchIcon(ctx context.Context, filename string) (Result, error) {
for _, fileType := range []string{"svg", "webp", "png"} {
result, err := fetchKnownIcon(ctx, NewSelfhStIconURL(filename, fileType))
result, err := fetchKnownIcon(ctx, icons.NewURL(icons.SourceSelfhSt, filename, fileType))
if err == nil {
return result, err
}
result, err = fetchKnownIcon(ctx, NewWalkXCodeIconURL(filename, fileType))
result, err = fetchKnownIcon(ctx, icons.NewURL(icons.SourceWalkXCode, filename, fileType))
if err == nil {
return result, err
}
@@ -150,10 +152,10 @@ type contextValue struct {
uri string
}
func FindIcon(ctx context.Context, r route, uri string, variant IconVariant) (FetchResult, error) {
func FindIcon(ctx context.Context, r route, uri string, variant icons.Variant) (Result, error) {
for _, ref := range r.References() {
ref = sanitizeName(ref)
if variant != IconVariantNone {
if variant != icons.VariantNone {
ref += "-" + string(variant)
}
result, err := fetchIcon(ctx, ref)
@@ -162,18 +164,21 @@ func FindIcon(ctx context.Context, r route, uri string, variant IconVariant) (Fe
}
}
if r, ok := r.(httpRoute); ok {
if mon := r.HealthMonitor(); mon != nil && !mon.Status().Good() {
return FetchResultWithErrorf(http.StatusServiceUnavailable, "service unavailable")
}
// fallback to parse html
return findIconSlowCached(context.WithValue(ctx, "route", contextValue{r: r, uri: uri}), r.Key())
}
return FetchResultWithErrorf(http.StatusNotFound, "no icon found")
}
var findIconSlowCached = cache.NewKeyFunc(func(ctx context.Context, key string) (FetchResult, error) {
var findIconSlowCached = cache.NewKeyFunc(func(ctx context.Context, key string) (Result, error) {
v := ctx.Value("route").(contextValue)
return findIconSlow(ctx, v.r, v.uri, nil)
}).WithMaxEntries(200).Build() // no retries, no ttl
}).WithMaxEntries(200).WithRetriesConstantBackoff(math.MaxInt, 15*time.Second).Build() // infinite retries, 15 seconds interval
func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string) (FetchResult, error) {
func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string) (Result, error) {
select {
case <-ctx.Done():
return FetchResultWithErrorf(http.StatusBadGateway, "request timeout")

View File

@@ -1,9 +1,10 @@
package homepage
package iconfetch
import (
"net/http"
nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/pool"
)
@@ -12,6 +13,7 @@ type route interface {
ProviderName() string
References() []string
TargetURL() *nettypes.URL
HealthMonitor() types.HealthMonitor
}
type httpRoute interface {

View File

@@ -0,0 +1,17 @@
package icons
import (
"fmt"
"strings"
)
type Key string
func NewKey(source Source, reference string) Key {
return Key(fmt.Sprintf("%s/%s", source, reference))
}
func (k Key) SourceRef() (Source, string) {
source, ref, _ := strings.Cut(string(k), "/")
return Source(source), ref
}

View File

@@ -1,8 +1,7 @@
package homepage
package iconlist
import (
"context"
"fmt"
"net/http"
"slices"
"strings"
@@ -12,6 +11,7 @@ import (
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/homepage/icons"
"github.com/yusing/godoxy/internal/serialization"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/intern"
@@ -21,60 +21,19 @@ import (
)
type (
IconKey string
IconMap map[IconKey]*IconMeta
IconMap map[icons.Key]*icons.Meta
IconList []string
IconMeta struct {
SVG bool `json:"SVG"`
PNG bool `json:"PNG"`
WebP bool `json:"WebP"`
Light bool `json:"Light"`
Dark bool `json:"Dark"`
DisplayName string `json:"-"`
Tag string `json:"-"`
}
IconMetaSearch struct {
*IconMeta
Source IconSource `json:"Source"`
Ref string `json:"Ref"`
IconMetaSearch struct {
*icons.Meta
Source icons.Source `json:"Source"`
Ref string `json:"Ref"`
rank int
}
)
func (icon *IconMeta) Filenames(ref string) []string {
filenames := make([]string, 0)
if icon.SVG {
filenames = append(filenames, ref+".svg")
if icon.Light {
filenames = append(filenames, ref+"-light.svg")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.svg")
}
}
if icon.PNG {
filenames = append(filenames, ref+".png")
if icon.Light {
filenames = append(filenames, ref+"-light.png")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.png")
}
}
if icon.WebP {
filenames = append(filenames, ref+".webp")
if icon.Light {
filenames = append(filenames, ref+"-light.webp")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.webp")
}
}
return filenames
}
const updateInterval = 2 * time.Hour
var iconsCache synk.Value[IconMap]
@@ -84,16 +43,17 @@ const (
selfhstIcons = "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/index.json"
)
func NewIconKey(source IconSource, reference string) IconKey {
return IconKey(fmt.Sprintf("%s/%s", source, reference))
type provider struct{}
func (p provider) HasIcon(u *icons.URL) bool {
return HasIcon(u)
}
func (k IconKey) SourceRef() (IconSource, string) {
source, ref, _ := strings.Cut(string(k), "/")
return IconSource(source), ref
func init() {
icons.SetProvider(provider{})
}
func InitIconListCache() {
func InitCache() {
m := make(IconMap)
err := serialization.LoadJSONIfExist(common.IconListCachePath, &m)
if err != nil {
@@ -196,10 +156,10 @@ func SearchIcons(keyword string, limit int) []*IconMetaSearch {
source, ref := k.SourceRef()
ranked := &IconMetaSearch{
Source: source,
Ref: ref,
IconMeta: icon,
rank: rank,
Source: source,
Ref: ref,
Meta: icon,
rank: rank,
}
// Sorted insert based on rank (lower rank = better match)
insertPos, _ := slices.BinarySearchFunc(results, ranked, sortByRank)
@@ -213,7 +173,7 @@ func SearchIcons(keyword string, limit int) []*IconMetaSearch {
return results[:min(len(results), limit)]
}
func HasIcon(icon *IconURL) bool {
func HasIcon(icon *icons.URL) bool {
if icon.Extra == nil {
return false
}
@@ -241,11 +201,11 @@ type HomepageMeta struct {
Tag string
}
func GetHomepageMeta(ref string) (HomepageMeta, bool) {
meta, ok := ListAvailableIcons()[NewIconKey(IconSourceSelfhSt, ref)]
func GetMetadata(ref string) (HomepageMeta, bool) {
meta, ok := ListAvailableIcons()[icons.NewKey(icons.SourceSelfhSt, ref)]
// these info is not available in walkxcode
// if !ok {
// meta, ok = iconsCache.Icons[NewIconKey(IconSourceWalkXCode, ref)]
// meta, ok = iconsCache.Icons[icons.NewIconKey(icons.IconSourceWalkXCode, ref)]
// }
if !ok {
return HomepageMeta{}, false
@@ -317,14 +277,14 @@ func UpdateWalkxCodeIcons(m IconMap) error {
}
for fileType, files := range data {
var setExt func(icon *IconMeta)
var setExt func(icon *icons.Meta)
switch fileType {
case "png":
setExt = func(icon *IconMeta) { icon.PNG = true }
setExt = func(icon *icons.Meta) { icon.PNG = true }
case "svg":
setExt = func(icon *IconMeta) { icon.SVG = true }
setExt = func(icon *icons.Meta) { icon.SVG = true }
case "webp":
setExt = func(icon *IconMeta) { icon.WebP = true }
setExt = func(icon *icons.Meta) { icon.WebP = true }
}
for _, f := range files {
f = strings.TrimSuffix(f, "."+fileType)
@@ -336,10 +296,10 @@ func UpdateWalkxCodeIcons(m IconMap) error {
if isDark {
f = strings.TrimSuffix(f, "-dark")
}
key := NewIconKey(IconSourceWalkXCode, f)
key := icons.NewKey(icons.SourceWalkXCode, f)
icon, ok := m[key]
if !ok {
icon = new(IconMeta)
icon = new(icons.Meta)
m[key] = icon
}
setExt(icon)
@@ -401,7 +361,7 @@ func UpdateSelfhstIcons(m IconMap) error {
tag, _, _ = strings.Cut(item.Tags, ",")
tag = strings.TrimSpace(tag)
}
icon := &IconMeta{
icon := &icons.Meta{
DisplayName: item.Name,
Tag: intern.Make(tag).Value(),
SVG: item.SVG == "Yes",
@@ -410,7 +370,7 @@ func UpdateSelfhstIcons(m IconMap) error {
Light: item.Light == "Yes",
Dark: item.Dark == "Yes",
}
key := NewIconKey(IconSourceSelfhSt, item.Reference)
key := icons.NewKey(icons.SourceSelfhSt, item.Reference)
m[key] = icon
}
return nil

View File

@@ -1,9 +1,10 @@
package homepage_test
package iconlist_test
import (
"testing"
. "github.com/yusing/godoxy/internal/homepage"
. "github.com/yusing/godoxy/internal/homepage/icons"
. "github.com/yusing/godoxy/internal/homepage/icons/list"
)
const walkxcodeIcons = `{
@@ -69,8 +70,8 @@ const selfhstIcons = `[
]`
type testCases struct {
Key IconKey
IconMeta
Key Key
Meta
}
func runTests(t *testing.T, iconsCache IconMap, test []testCases) {
@@ -109,8 +110,8 @@ func TestListWalkxCodeIcons(t *testing.T) {
}
test := []testCases{
{
Key: NewIconKey(IconSourceWalkXCode, "app1"),
IconMeta: IconMeta{
Key: NewKey(SourceWalkXCode, "app1"),
Meta: Meta{
SVG: true,
PNG: true,
WebP: true,
@@ -118,15 +119,15 @@ func TestListWalkxCodeIcons(t *testing.T) {
},
},
{
Key: NewIconKey(IconSourceWalkXCode, "app2"),
IconMeta: IconMeta{
Key: NewKey(SourceWalkXCode, "app2"),
Meta: Meta{
PNG: true,
WebP: true,
},
},
{
Key: NewIconKey(IconSourceWalkXCode, "karakeep"),
IconMeta: IconMeta{
Key: NewKey(SourceWalkXCode, "karakeep"),
Meta: Meta{
SVG: true,
PNG: true,
WebP: true,
@@ -149,8 +150,8 @@ func TestListSelfhstIcons(t *testing.T) {
}
test := []testCases{
{
Key: NewIconKey(IconSourceSelfhSt, "2fauth"),
IconMeta: IconMeta{
Key: NewKey(SourceSelfhSt, "2fauth"),
Meta: Meta{
SVG: true,
PNG: true,
WebP: true,
@@ -160,16 +161,16 @@ func TestListSelfhstIcons(t *testing.T) {
},
},
{
Key: NewIconKey(IconSourceSelfhSt, "dittofeed"),
IconMeta: IconMeta{
Key: NewKey(SourceSelfhSt, "dittofeed"),
Meta: Meta{
PNG: true,
WebP: true,
DisplayName: "Dittofeed",
},
},
{
Key: NewIconKey(IconSourceSelfhSt, "ars-technica"),
IconMeta: IconMeta{
Key: NewKey(SourceSelfhSt, "ars-technica"),
Meta: Meta{
SVG: true,
PNG: true,
WebP: true,

View File

@@ -0,0 +1,43 @@
package icons
type Meta struct {
SVG bool `json:"SVG"`
PNG bool `json:"PNG"`
WebP bool `json:"WebP"`
Light bool `json:"Light"`
Dark bool `json:"Dark"`
DisplayName string `json:"-"`
Tag string `json:"-"`
}
func (icon *Meta) Filenames(ref string) []string {
filenames := make([]string, 0)
if icon.SVG {
filenames = append(filenames, ref+".svg")
if icon.Light {
filenames = append(filenames, ref+"-light.svg")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.svg")
}
}
if icon.PNG {
filenames = append(filenames, ref+".png")
if icon.Light {
filenames = append(filenames, ref+"-light.png")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.png")
}
}
if icon.WebP {
filenames = append(filenames, ref+".webp")
if icon.Light {
filenames = append(filenames, ref+"-light.webp")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.webp")
}
}
return filenames
}

View File

@@ -0,0 +1,21 @@
package icons
import "sync/atomic"
type Provider interface {
HasIcon(u *URL) bool
}
var provider atomic.Value
func SetProvider(p Provider) {
provider.Store(p)
}
func hasIcon(u *URL) bool {
v := provider.Load()
if v == nil {
return false
}
return v.(Provider).HasIcon(u)
}

View File

@@ -1,4 +1,4 @@
package homepage
package icons
import (
"fmt"
@@ -8,43 +8,43 @@ import (
)
type (
IconURL struct {
IconSource `json:"source"`
URL struct {
Source `json:"source"`
FullURL *string `json:"value,omitempty"` // only for absolute/relative icons
Extra *IconExtra `json:"extra,omitempty"` // only for walkxcode/selfhst icons
FullURL *string `json:"value,omitempty"` // only for absolute/relative icons
Extra *Extra `json:"extra,omitempty"` // only for walkxcode/selfhst icons
}
IconExtra struct {
Key IconKey `json:"key"`
Ref string `json:"ref"`
FileType string `json:"file_type"`
IsLight bool `json:"is_light"`
IsDark bool `json:"is_dark"`
Extra struct {
Key Key `json:"key"`
Ref string `json:"ref"`
FileType string `json:"file_type"`
IsLight bool `json:"is_light"`
IsDark bool `json:"is_dark"`
}
IconSource string
IconVariant string
Source string
Variant string
)
const (
IconSourceAbsolute IconSource = "https://"
IconSourceRelative IconSource = "@target"
IconSourceWalkXCode IconSource = "@walkxcode"
IconSourceSelfhSt IconSource = "@selfhst"
SourceAbsolute Source = "https://"
SourceRelative Source = "@target"
SourceWalkXCode Source = "@walkxcode"
SourceSelfhSt Source = "@selfhst"
)
const (
IconVariantNone IconVariant = ""
IconVariantLight IconVariant = "light"
IconVariantDark IconVariant = "dark"
VariantNone Variant = ""
VariantLight Variant = "light"
VariantDark Variant = "dark"
)
var ErrInvalidIconURL = gperr.New("invalid icon url")
func NewIconURL(source IconSource, refOrName, format string) *IconURL {
func NewURL(source Source, refOrName, format string) *URL {
switch source {
case IconSourceWalkXCode, IconSourceSelfhSt:
case SourceWalkXCode, SourceSelfhSt:
default:
panic("invalid icon source")
}
@@ -56,10 +56,10 @@ func NewIconURL(source IconSource, refOrName, format string) *IconURL {
isDark = true
refOrName = strings.TrimSuffix(refOrName, "-dark")
}
return &IconURL{
IconSource: source,
Extra: &IconExtra{
Key: NewIconKey(source, refOrName),
return &URL{
Source: source,
Extra: &Extra{
Key: NewKey(source, refOrName),
FileType: format,
Ref: refOrName,
IsLight: isLight,
@@ -68,53 +68,42 @@ func NewIconURL(source IconSource, refOrName, format string) *IconURL {
}
}
func NewSelfhStIconURL(refOrName, format string) *IconURL {
return NewIconURL(IconSourceSelfhSt, refOrName, format)
func (u *URL) HasIcon() bool {
return hasIcon(u)
}
func NewWalkXCodeIconURL(name, format string) *IconURL {
return NewIconURL(IconSourceWalkXCode, name, format)
}
// HasIcon checks if the icon referenced by the IconURL exists in the cache based on its source.
// Returns false if the icon does not exist for IconSourceSelfhSt or IconSourceWalkXCode,
// otherwise returns true.
func (u *IconURL) HasIcon() bool {
return HasIcon(u)
}
func (u *IconURL) WithVariant(variant IconVariant) *IconURL {
switch u.IconSource {
case IconSourceWalkXCode, IconSourceSelfhSt:
func (u *URL) WithVariant(variant Variant) *URL {
switch u.Source {
case SourceWalkXCode, SourceSelfhSt:
default:
return u // no variant for absolute/relative icons
}
var extra *IconExtra
var extra *Extra
if u.Extra != nil {
extra = &IconExtra{
extra = &Extra{
Key: u.Extra.Key,
Ref: u.Extra.Ref,
FileType: u.Extra.FileType,
IsLight: variant == IconVariantLight,
IsDark: variant == IconVariantDark,
IsLight: variant == VariantLight,
IsDark: variant == VariantDark,
}
extra.Ref = strings.TrimSuffix(extra.Ref, "-light")
extra.Ref = strings.TrimSuffix(extra.Ref, "-dark")
}
return &IconURL{
IconSource: u.IconSource,
FullURL: u.FullURL,
Extra: extra,
return &URL{
Source: u.Source,
FullURL: u.FullURL,
Extra: extra,
}
}
// Parse implements strutils.Parser.
func (u *IconURL) Parse(v string) error {
func (u *URL) Parse(v string) error {
return u.parse(v, true)
}
func (u *IconURL) parse(v string, checkExists bool) error {
func (u *URL) parse(v string, checkExists bool) error {
if v == "" {
return ErrInvalidIconURL
}
@@ -126,19 +115,19 @@ func (u *IconURL) parse(v string, checkExists bool) error {
switch beforeSlash {
case "http:", "https:":
u.FullURL = &v
u.IconSource = IconSourceAbsolute
u.Source = SourceAbsolute
case "@target", "": // @target/favicon.ico, /favicon.ico
url := v[slashIndex:]
if url == "/" {
return ErrInvalidIconURL.Withf("%s", "empty path")
}
u.FullURL = &url
u.IconSource = IconSourceRelative
u.Source = SourceRelative
case "@selfhst", "@walkxcode": // selfh.st / walkxcode Icons, @selfhst/<reference>.<format>
if beforeSlash == "@selfhst" {
u.IconSource = IconSourceSelfhSt
u.Source = SourceSelfhSt
} else {
u.IconSource = IconSourceWalkXCode
u.Source = SourceWalkXCode
}
parts := strings.Split(v[slashIndex+1:], ".")
if len(parts) != 2 {
@@ -161,15 +150,15 @@ func (u *IconURL) parse(v string, checkExists bool) error {
isDark = true
reference = strings.TrimSuffix(reference, "-dark")
}
u.Extra = &IconExtra{
Key: NewIconKey(u.IconSource, reference),
u.Extra = &Extra{
Key: NewKey(u.Source, reference),
FileType: format,
Ref: reference,
IsLight: isLight,
IsDark: isDark,
}
if checkExists && !u.HasIcon() {
return ErrInvalidIconURL.Withf("no such icon %s.%s from %s", reference, format, u.IconSource)
return ErrInvalidIconURL.Withf("no such icon %s.%s from %s", reference, format, u.Source)
}
default:
return ErrInvalidIconURL.Subject(v)
@@ -178,7 +167,7 @@ func (u *IconURL) parse(v string, checkExists bool) error {
return nil
}
func (u *IconURL) URL() string {
func (u *URL) URL() string {
if u.FullURL != nil {
return *u.FullURL
}
@@ -191,16 +180,16 @@ func (u *IconURL) URL() string {
} else if u.Extra.IsDark {
filename += "-dark"
}
switch u.IconSource {
case IconSourceWalkXCode:
switch u.Source {
case SourceWalkXCode:
return fmt.Sprintf("https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/%s/%s.%s", u.Extra.FileType, filename, u.Extra.FileType)
case IconSourceSelfhSt:
case SourceSelfhSt:
return fmt.Sprintf("https://cdn.jsdelivr.net/gh/selfhst/icons/%s/%s.%s", u.Extra.FileType, filename, u.Extra.FileType)
}
return ""
}
func (u *IconURL) String() string {
func (u *URL) String() string {
if u.FullURL != nil {
return *u.FullURL
}
@@ -213,14 +202,14 @@ func (u *IconURL) String() string {
} else if u.Extra.IsDark {
suffix = "-dark"
}
return fmt.Sprintf("%s/%s%s.%s", u.IconSource, u.Extra.Ref, suffix, u.Extra.FileType)
return fmt.Sprintf("%s/%s%s.%s", u.Source, u.Extra.Ref, suffix, u.Extra.FileType)
}
func (u *IconURL) MarshalText() ([]byte, error) {
func (u *URL) MarshalText() ([]byte, error) {
return []byte(u.String()), nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (u *IconURL) UnmarshalText(data []byte) error {
func (u *URL) UnmarshalText(data []byte) error {
return u.parse(string(data), false)
}

View File

@@ -1,9 +1,9 @@
package homepage_test
package icons_test
import (
"testing"
. "github.com/yusing/godoxy/internal/homepage"
. "github.com/yusing/godoxy/internal/homepage/icons"
expect "github.com/yusing/goutils/testing"
)
@@ -15,31 +15,31 @@ func TestIconURL(t *testing.T) {
tests := []struct {
name string
input string
wantValue *IconURL
wantValue *URL
wantErr bool
}{
{
name: "absolute",
input: "http://example.com/icon.png",
wantValue: &IconURL{
FullURL: strPtr("http://example.com/icon.png"),
IconSource: IconSourceAbsolute,
wantValue: &URL{
FullURL: strPtr("http://example.com/icon.png"),
Source: SourceAbsolute,
},
},
{
name: "relative",
input: "@target/icon.png",
wantValue: &IconURL{
FullURL: strPtr("/icon.png"),
IconSource: IconSourceRelative,
wantValue: &URL{
FullURL: strPtr("/icon.png"),
Source: SourceRelative,
},
},
{
name: "relative2",
input: "/icon.png",
wantValue: &IconURL{
FullURL: strPtr("/icon.png"),
IconSource: IconSourceRelative,
wantValue: &URL{
FullURL: strPtr("/icon.png"),
Source: SourceRelative,
},
},
{
@@ -55,10 +55,10 @@ func TestIconURL(t *testing.T) {
{
name: "walkxcode",
input: "@walkxcode/adguard-home.png",
wantValue: &IconURL{
IconSource: IconSourceWalkXCode,
Extra: &IconExtra{
Key: NewIconKey(IconSourceWalkXCode, "adguard-home"),
wantValue: &URL{
Source: SourceWalkXCode,
Extra: &Extra{
Key: NewKey(SourceWalkXCode, "adguard-home"),
FileType: "png",
Ref: "adguard-home",
},
@@ -67,10 +67,10 @@ func TestIconURL(t *testing.T) {
{
name: "walkxcode_light",
input: "@walkxcode/pfsense-light.png",
wantValue: &IconURL{
IconSource: IconSourceWalkXCode,
Extra: &IconExtra{
Key: NewIconKey(IconSourceWalkXCode, "pfsense"),
wantValue: &URL{
Source: SourceWalkXCode,
Extra: &Extra{
Key: NewKey(SourceWalkXCode, "pfsense"),
FileType: "png",
Ref: "pfsense",
IsLight: true,
@@ -85,10 +85,10 @@ func TestIconURL(t *testing.T) {
{
name: "selfh.st_valid",
input: "@selfhst/adguard-home.webp",
wantValue: &IconURL{
IconSource: IconSourceSelfhSt,
Extra: &IconExtra{
Key: NewIconKey(IconSourceSelfhSt, "adguard-home"),
wantValue: &URL{
Source: SourceSelfhSt,
Extra: &Extra{
Key: NewKey(SourceSelfhSt, "adguard-home"),
FileType: "webp",
Ref: "adguard-home",
},
@@ -97,10 +97,10 @@ func TestIconURL(t *testing.T) {
{
name: "selfh.st_light",
input: "@selfhst/adguard-home-light.png",
wantValue: &IconURL{
IconSource: IconSourceSelfhSt,
Extra: &IconExtra{
Key: NewIconKey(IconSourceSelfhSt, "adguard-home"),
wantValue: &URL{
Source: SourceSelfhSt,
Extra: &Extra{
Key: NewKey(SourceSelfhSt, "adguard-home"),
FileType: "png",
Ref: "adguard-home",
IsLight: true,
@@ -110,10 +110,10 @@ func TestIconURL(t *testing.T) {
{
name: "selfh.st_dark",
input: "@selfhst/adguard-home-dark.svg",
wantValue: &IconURL{
IconSource: IconSourceSelfhSt,
Extra: &IconExtra{
Key: NewIconKey(IconSourceSelfhSt, "adguard-home"),
wantValue: &URL{
Source: SourceSelfhSt,
Extra: &Extra{
Key: NewKey(SourceSelfhSt, "adguard-home"),
FileType: "svg",
Ref: "adguard-home",
IsDark: true,
@@ -143,7 +143,7 @@ func TestIconURL(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
u := &IconURL{}
u := &URL{}
err := u.Parse(tc.input)
if tc.wantErr {
expect.ErrorIs(t, ErrInvalidIconURL, err)

View File

@@ -7,7 +7,8 @@ import (
"net/http"
"strconv"
"github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/homepage/icons"
iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch"
idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types"
gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http"
@@ -99,18 +100,18 @@ func (w *Watcher) handleWakeEventsSSE(rw http.ResponseWriter, r *http.Request) {
}
}
func (w *Watcher) getFavIcon(ctx context.Context) (result homepage.FetchResult, err error) {
func (w *Watcher) getFavIcon(ctx context.Context) (result iconfetch.Result, err error) {
r := w.route
hp := r.HomepageItem()
if hp.Icon != nil {
if hp.Icon.IconSource == homepage.IconSourceRelative {
result, err = homepage.FindIcon(ctx, r, *hp.Icon.FullURL, homepage.IconVariantNone)
if hp.Icon.Source == icons.SourceRelative {
result, err = iconfetch.FindIcon(ctx, r, *hp.Icon.FullURL, icons.VariantNone)
} else {
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon)
result, err = iconfetch.FetchFavIconFromURL(ctx, hp.Icon)
}
} else {
// try extract from "link[rel=icon]"
result, err = homepage.FindIcon(ctx, r, "/", homepage.IconVariantNone)
result, err = iconfetch.FindIcon(ctx, r, "/", icons.VariantNone)
}
if result.StatusCode == 0 {
result.StatusCode = http.StatusOK

View File

@@ -20,6 +20,7 @@ import (
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/health/monitor"
"github.com/yusing/godoxy/internal/homepage"
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list"
homepagecfg "github.com/yusing/godoxy/internal/homepage/types"
netutils "github.com/yusing/godoxy/internal/net"
nettypes "github.com/yusing/godoxy/internal/net/types"
@@ -54,7 +55,7 @@ type (
route.HTTPConfig
PathPatterns []string `json:"path_patterns,omitempty" extensions:"x-nullable"`
Rules rules.Rules `json:"rules,omitempty" extension:"x-nullable"`
Rules rules.Rules `json:"rules,omitempty" extensions:"x-nullable"`
RuleFile string `json:"rule_file,omitempty" extensions:"x-nullable"`
HealthCheck types.HealthCheckConfig `json:"healthcheck,omitempty" extensions:"x-nullable"` // null on load-balancer routes
LoadBalance *types.LoadBalancerConfig `json:"load_balance,omitempty" extensions:"x-nullable"`
@@ -478,13 +479,18 @@ func (r *Route) TargetURL() *nettypes.URL {
}
func (r *Route) References() []string {
aliasRef, _, ok := strings.Cut(r.Alias, ".")
if !ok {
aliasRef = r.Alias
}
if r.Container != nil {
if r.Container.ContainerName != r.Alias {
return []string{r.Container.ContainerName, r.Alias, r.Container.Image.Name, r.Container.Image.Author}
return []string{r.Container.ContainerName, aliasRef, r.Container.Image.Name, r.Container.Image.Author}
}
return []string{r.Container.Image.Name, r.Alias, r.Container.Image.Author}
return []string{r.Container.Image.Name, aliasRef, r.Container.Image.Author}
}
return []string{r.Alias}
return []string{aliasRef}
}
// Name implements pool.Object.
@@ -849,7 +855,7 @@ func (r *Route) FinalizeHomepageConfig() {
hp := r.Homepage
refs := r.References()
for _, ref := range refs {
meta, ok := homepage.GetHomepageMeta(ref)
meta, ok := iconlist.GetMetadata(ref)
if ok {
if hp.Name == "" {
hp.Name = meta.DisplayName

View File

@@ -15,6 +15,7 @@ var (
ErrInvalidArguments = gperr.New("invalid arguments")
ErrInvalidOnTarget = gperr.New("invalid `rule.on` target")
ErrInvalidCommandSequence = gperr.New("invalid command sequence")
ErrMultipleDefaultRules = gperr.New("multiple default rules")
// vars errors
ErrNoArgProvided = gperr.New("no argument provided")

View File

@@ -26,6 +26,7 @@ func (on *RuleOn) Check(w http.ResponseWriter, r *http.Request) bool {
}
const (
OnDefault = "default"
OnHeader = "header"
OnQuery = "query"
OnCookie = "cookie"
@@ -50,6 +51,22 @@ var checkers = map[string]struct {
builder func(args any) CheckFunc
isResponseChecker bool
}{
OnDefault: {
help: Help{
command: OnDefault,
description: makeLines(
"The default rule is matched when no other rules are matched.",
),
args: map[string]string{},
},
validate: func(args []string) (any, gperr.Error) {
if len(args) != 0 {
return nil, ErrExpectNoArg
}
return nil, nil
},
builder: func(args any) CheckFunc { return func(w http.ResponseWriter, r *http.Request) bool { return false } }, // this should never be called
},
OnHeader: {
help: Help{
command: OnHeader,

View File

@@ -7,6 +7,7 @@ import (
"github.com/quic-go/quic-go/http3"
"github.com/rs/zerolog/log"
gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http"
"golang.org/x/net/http2"
@@ -57,6 +58,19 @@ func (rule *Rule) IsResponseRule() bool {
return rule.On.IsResponseChecker() || rule.Do.IsResponseHandler()
}
func (rules Rules) Validate() gperr.Error {
var defaultRulesFound []int
for i, rule := range rules {
if rule.Name == "default" || rule.On.raw == OnDefault {
defaultRulesFound = append(defaultRulesFound, i)
}
}
if len(defaultRulesFound) > 1 {
return ErrMultipleDefaultRules.Withf("found %d", len(defaultRulesFound))
}
return nil
}
// BuildHandler returns a http.HandlerFunc that implements the rules.
func (rules Rules) BuildHandler(up http.HandlerFunc) http.HandlerFunc {
if len(rules) == 0 {
@@ -74,7 +88,7 @@ func (rules Rules) BuildHandler(up http.HandlerFunc) http.HandlerFunc {
var nonDefaultRules Rules
hasDefaultRule := false
for i, rule := range rules {
if rule.Name == "default" {
if rule.Name == "default" || rule.On.raw == OnDefault {
defaultRule = rule
hasDefaultRule = true
} else {

View File

@@ -0,0 +1,52 @@
package rules
import (
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/serialization"
)
func TestRulesValidate(t *testing.T) {
tests := []struct {
name string
rules string
want error
}{
{
name: "no default rule",
rules: `
- name: rule1
on: header Host example.com
do: pass
`,
},
{
name: "multiple default rules",
rules: `
- name: default
do: pass
- name: rule1
on: default
do: pass
`,
want: ErrMultipleDefaultRules,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var rules Rules
convertible, err := serialization.ConvertString(strings.TrimSpace(tt.rules), reflect.ValueOf(&rules))
require.True(t, convertible)
if tt.want == nil {
assert.NoError(t, err)
return
}
assert.ErrorIs(t, err, tt.want)
})
}
}

View File

@@ -116,9 +116,9 @@ func (r *StreamRoute) initStream() (nettypes.Stream, error) {
switch rScheme {
case "tcp":
return stream.NewTCPTCPStream(lurl.Scheme, rurl.Scheme, laddr, rurl.Host)
return stream.NewTCPTCPStream(lurl.Scheme, rurl.Scheme, laddr, rurl.Host, r.GetAgent())
case "udp":
return stream.NewUDPUDPStream(lurl.Scheme, rurl.Scheme, laddr, rurl.Host)
return stream.NewUDPUDPStream(lurl.Scheme, rurl.Scheme, laddr, rurl.Host, r.GetAgent())
}
return nil, fmt.Errorf("unknown scheme: %s", rurl.Scheme)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/pires/go-proxyproto"
"github.com/rs/zerolog"
"github.com/yusing/godoxy/internal/acl"
"github.com/yusing/godoxy/internal/agentpool"
"github.com/yusing/godoxy/internal/entrypoint"
nettypes "github.com/yusing/godoxy/internal/net/types"
ioutils "github.com/yusing/goutils/io"
@@ -21,6 +22,7 @@ type TCPTCPStream struct {
laddr *net.TCPAddr
dst *net.TCPAddr
agent *agentpool.Agent
preDial nettypes.HookFunc
onRead nettypes.HookFunc
@@ -28,7 +30,7 @@ type TCPTCPStream struct {
closed atomic.Bool
}
func NewTCPTCPStream(network, dstNetwork, listenAddr, dstAddr string) (nettypes.Stream, error) {
func NewTCPTCPStream(network, dstNetwork, listenAddr, dstAddr string, agent *agentpool.Agent) (nettypes.Stream, error) {
dst, err := net.ResolveTCPAddr(dstNetwork, dstAddr)
if err != nil {
return nil, err
@@ -37,7 +39,7 @@ func NewTCPTCPStream(network, dstNetwork, listenAddr, dstAddr string) (nettypes.
if err != nil {
return nil, err
}
return &TCPTCPStream{network: network, dstNetwork: dstNetwork, laddr: laddr, dst: dst}, nil
return &TCPTCPStream{network: network, dstNetwork: dstNetwork, laddr: laddr, dst: dst, agent: agent}, nil
}
func (s *TCPTCPStream) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) {
@@ -130,7 +132,15 @@ func (s *TCPTCPStream) handle(ctx context.Context, conn net.Conn) {
return
}
dstConn, err := net.DialTCP(s.dstNetwork, nil, s.dst)
var (
dstConn net.Conn
err error
)
if s.agent != nil {
dstConn, err = s.agent.NewTCPClient(s.dst.String())
} else {
dstConn, err = net.DialTCP(s.dstNetwork, nil, s.dst)
}
if err != nil {
if !s.closed.Load() {
logErr(s, err, "failed to dial destination")
@@ -144,7 +154,7 @@ func (s *TCPTCPStream) handle(ctx context.Context, conn net.Conn) {
}
src := conn
dst := net.Conn(dstConn)
dst := dstConn
if s.onRead != nil {
src = &wrapperConn{
Conn: conn,

View File

@@ -11,6 +11,7 @@ import (
"github.com/rs/zerolog"
"github.com/yusing/godoxy/internal/acl"
"github.com/yusing/godoxy/internal/agentpool"
nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/goutils/synk"
"go.uber.org/atomic"
@@ -24,6 +25,7 @@ type UDPUDPStream struct {
laddr *net.UDPAddr
dst *net.UDPAddr
agent *agentpool.Agent
preDial nettypes.HookFunc
onRead nettypes.HookFunc
@@ -53,7 +55,7 @@ const (
var bufPool = synk.GetSizedBytesPool()
func NewUDPUDPStream(network, dstNetwork, listenAddr, dstAddr string) (nettypes.Stream, error) {
func NewUDPUDPStream(network, dstNetwork, listenAddr, dstAddr string, agent *agentpool.Agent) (nettypes.Stream, error) {
dst, err := net.ResolveUDPAddr(dstNetwork, dstAddr)
if err != nil {
return nil, err
@@ -67,6 +69,7 @@ func NewUDPUDPStream(network, dstNetwork, listenAddr, dstAddr string) (nettypes.
dstNetwork: dstNetwork,
laddr: laddr,
dst: dst,
agent: agent,
conns: make(map[string]*udpUDPConn),
}, nil
}
@@ -195,7 +198,11 @@ func (s *UDPUDPStream) createConnection(ctx context.Context, srcAddr *net.UDPAdd
dstConn net.Conn
err error
)
dstConn, err = net.DialUDP(s.dstNetwork, nil, s.dst)
if s.agent != nil {
dstConn, err = s.agent.NewUDPClient(s.dst.String())
} else {
dstConn, err = net.DialUDP(s.dst.Network(), nil, s.dst)
}
if err != nil {
logErr(s, err, "failed to dial dst")
return nil, false

View File

@@ -449,51 +449,66 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.
}
return mapUnmarshalValidate(obj, dst.Addr(), checkValidateTag)
case srcKind == reflect.Slice: // slice to slice
srcLen := src.Len()
if srcLen == 0 {
dst.SetZero()
return nil
}
if dstT.Kind() != reflect.Slice {
return ErrUnsupportedConversion.Subject(dstT.String() + " to " + srcT.String())
}
var sliceErrs gperr.Builder
i := 0
gi.ReflectInitSlice(dst, srcLen, srcLen)
for j, v := range src.Seq2() {
err := Convert(v, dst.Index(i), checkValidateTag)
if err != nil {
sliceErrs.Add(err.Subjectf("[%d]", j))
continue
}
i++
}
if err := sliceErrs.Error(); err != nil {
dst.SetLen(i) // shrink to number of elements that were successfully converted
return err
}
return nil
return ConvertSlice(src, dst, checkValidateTag)
}
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT.String(), dstT.String())
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
}
var parserType = reflect.TypeFor[strutils.Parser]()
func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error {
if src.Kind() != reflect.Slice {
return Convert(src, dst, checkValidateTag)
}
srcLen := src.Len()
if srcLen == 0 {
dst.SetZero()
return nil
}
if dst.Kind() != reflect.Slice {
return ErrUnsupportedConversion.Subjectf("%s to %s", dst.Type(), src.Type())
}
var sliceErrs gperr.Builder
numValid := 0
gi.ReflectInitSlice(dst, srcLen, srcLen)
for j := range srcLen {
err := Convert(src.Index(j), dst.Index(numValid), checkValidateTag)
if err != nil {
sliceErrs.Add(err.Subjectf("[%d]", j))
continue
}
numValid++
}
if dst.Type().Implements(reflect.TypeFor[CustomValidator]()) {
err := dst.Interface().(CustomValidator).Validate()
if err != nil {
sliceErrs.Add(err)
}
}
if err := sliceErrs.Error(); err != nil {
dst.SetLen(numValid) // shrink to number of elements that were successfully converted
return err
}
return nil
}
func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) {
convertible = true
dstT := dst.Type()
if dst.Kind() == reflect.Pointer {
if dst.IsNil() {
// Early return for empty string
if src == "" {
return true, nil
}
initPtr(dst)
}
dst = dst.Elem()
dstT = dst.Type()
}
if dst.Kind() == reflect.String {
dst.SetString(src)
return true, nil
}
// Early return for empty string
if src == "" {
@@ -501,6 +516,17 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
return true, nil
}
if dst.Kind() == reflect.String {
dst.SetString(src)
return true, nil
}
// check if (*T).Convertor is implemented
if addr := dst.Addr(); addr.Type().Implements(reflect.TypeFor[strutils.Parser]()) {
parser := addr.Interface().(strutils.Parser)
return true, gperr.Wrap(parser.Parse(src))
}
switch dstT {
case reflect.TypeFor[time.Duration]():
d, err := time.ParseDuration(src)
@@ -512,12 +538,6 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
default:
}
// check if (*T).Convertor is implemented
if dst.Addr().Type().Implements(parserType) {
parser := dst.Addr().Interface().(strutils.Parser)
return true, gperr.Wrap(parser.Parse(src))
}
if gi.ReflectIsNumeric(dst) || dst.Kind() == reflect.Bool {
err := gi.ReflectStrToNumBool(dst, src)
if err != nil {
@@ -527,29 +547,25 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
}
// yaml like
var tmp any
switch dst.Kind() {
case reflect.Slice:
// Avoid unnecessary TrimSpace if we can detect the format early
srcLen := len(src)
if srcLen == 0 {
return true, nil
}
// one liner is comma separated list
isMultiline := strings.ContainsRune(src, '\n')
if !isMultiline && src[0] != '-' {
isMultiline := strings.IndexByte(src, '\n') != -1
if !isMultiline && src[0] != '-' && src[0] != '[' {
values := strutils.CommaSeperatedList(src)
gi.ReflectInitSlice(dst, len(values), len(values))
size := len(values)
gi.ReflectInitSlice(dst, size, size)
var errs gperr.Builder
for i, v := range values {
_, err := ConvertString(v, dst.Index(i))
if err != nil {
errs.Add(err.Subjectf("[%d]", i))
errs.AddSubjectf(err, "[%d]", i)
}
}
err := errs.Error()
return true, err
if errs.HasError() {
return true, errs.Error()
}
return true, nil
}
sl := []any{}
@@ -557,18 +573,17 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
if err != nil {
return true, gperr.Wrap(err)
}
tmp = sl
return true, ConvertSlice(reflect.ValueOf(sl), dst, true)
case reflect.Map, reflect.Struct:
rawMap := SerializedObject{}
err := yaml.Unmarshal(unsafe.Slice(unsafe.StringData(src), len(src)), &rawMap)
if err != nil {
return true, gperr.Wrap(err)
}
tmp = rawMap
return true, mapUnmarshalValidate(rawMap, dst, true)
default:
return false, nil
}
return true, Convert(reflect.ValueOf(tmp), dst, true)
}
var envRegex = regexp.MustCompile(`\$\{([^}]+)\}`) // e.g. ${CLOUDFLARE_API_KEY}

View File

@@ -32,9 +32,9 @@ func BenchmarkDeserialize(b *testing.B) {
"c": "1,2,3",
"d": "a: a\nb: b\nc: c",
"e": "- a: a\n b: b\n c: c",
"f": map[string]any{"a": "a", "b": "456", "c": []string{"1", "2", "3"}},
"f": map[string]any{"a": "a", "b": "456", "c": `1,2,3`},
"g": map[string]any{"g1": "1.23", "g2": 123},
"h": []map[string]any{{"a": 123, "b": "456", "c": []string{"1", "2", "3"}}},
"h": []map[string]any{{"a": 123, "b": "456", "c": `["1","2","3"]`}},
"j": "1.23",
"k": 123,
}

View File

@@ -1,5 +1,4 @@
import { Glob } from "bun";
import { linkSync } from "fs";
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
import path from "path";
@@ -8,6 +7,8 @@ type ImplDoc = {
pkgPath: string;
/** File name in wiki `src/impl/`, e.g. "internal-health-check.md" */
docFileName: string;
/** VitePress route path (extensionless), e.g. "/impl/internal-health-check" */
docRoute: string;
/** Absolute source README path */
srcPathAbs: string;
/** Absolute destination doc path */
@@ -17,6 +18,8 @@ type ImplDoc = {
const START_MARKER = "// GENERATED-IMPL-SIDEBAR-START";
const END_MARKER = "// GENERATED-IMPL-SIDEBAR-END";
const skipSubmodules = ["internal/go-oidc/", "internal/gopsutil/"];
function escapeRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -25,6 +28,16 @@ function escapeSingleQuotedTs(s: string) {
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
function normalizeRepoUrl(raw: string) {
let url = (raw ?? "").trim();
if (!url) return "";
// Common typo: "https://https://github.com/..."
url = url.replace(/^https?:\/\/https?:\/\//i, "https://");
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
url = url.replace(/\/+$/, "");
return url;
}
function sanitizeFileStemFromPkgPath(pkgPath: string) {
// Convert a package path into a stable filename.
// Example: "internal/go-oidc/example" -> "internal-go-oidc-example"
@@ -37,6 +50,133 @@ function sanitizeFileStemFromPkgPath(pkgPath: string) {
return joined.replace(/-+/g, "-").replace(/^-|-$/g, "");
}
function splitUrlAndFragment(url: string): {
urlNoFragment: string;
fragment: string;
} {
const i = url.indexOf("#");
if (i === -1) return { urlNoFragment: url, fragment: "" };
return { urlNoFragment: url.slice(0, i), fragment: url.slice(i) };
}
function isExternalOrAbsoluteUrl(url: string) {
// - absolute site links: "/foo"
// - pure fragments: "#bar"
// - external schemes: "https:", "mailto:", "vscode:", etc.
// IMPORTANT: don't treat "config.go:29" as a scheme.
if (url.startsWith("/") || url.startsWith("#")) return true;
if (url.includes("://")) return true;
return /^(https?|mailto|tel|vscode|file|data|ssh|git):/i.test(url);
}
function isRepoSourceFilePath(filePath: string) {
// Conservative allow-list: avoid rewriting .md (non-README) which may be VitePress docs.
return /\.(go|ts|tsx|js|jsx|py|sh|yml|yaml|json|toml|env|css|html|txt)$/i.test(
filePath
);
}
function parseFileLineSuffix(urlNoFragment: string): {
filePath: string;
line?: string;
} {
// Match "file.ext:123" (line suffix), while leaving "file.ext" untouched.
const m = urlNoFragment.match(/^(.*?):(\d+)$/);
if (!m) return { filePath: urlNoFragment };
return { filePath: m[1] ?? urlNoFragment, line: m[2] };
}
function rewriteMarkdownLinksOutsideFences(
md: string,
rewriteInline: (url: string) => string
) {
const lines = md.split("\n");
let inFence = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const trimmed = line.trimStart();
if (trimmed.startsWith("```")) {
inFence = !inFence;
continue;
}
if (inFence) continue;
// Inline markdown links/images: [text](url "title") / ![alt](url)
lines[i] = line.replace(
/\]\(([^)\s]+)(\s+"[^"]*")?\)/g,
(_full, urlRaw: string, maybeTitle: string | undefined) => {
const rewritten = rewriteInline(urlRaw);
return `](${rewritten}${maybeTitle ?? ""})`;
}
);
}
return lines.join("\n");
}
function rewriteImplMarkdown(params: {
md: string;
pkgPath: string;
readmeRelToDocRoute: Map<string, string>;
dirPathToDocRoute: Map<string, string>;
repoUrl: string;
}) {
const { md, pkgPath, readmeRelToDocRoute, dirPathToDocRoute, repoUrl } =
params;
return rewriteMarkdownLinksOutsideFences(md, (urlRaw) => {
// Handle angle-bracketed destinations: (<./foo/README.md>)
const angleWrapped =
urlRaw.startsWith("<") && urlRaw.endsWith(">")
? urlRaw.slice(1, -1)
: urlRaw;
const { urlNoFragment, fragment } = splitUrlAndFragment(angleWrapped);
if (!urlNoFragment) return urlRaw;
if (isExternalOrAbsoluteUrl(urlNoFragment)) return urlRaw;
// 1) Directory links like "common" or "common/" that have a README
const dirPathNormalized = urlNoFragment.replace(/\/+$/, "");
if (dirPathToDocRoute.has(dirPathNormalized)) {
const rewritten = `${dirPathToDocRoute.get(
dirPathNormalized
)!}${fragment}`;
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
}
// 2) Intra-repo README links -> VitePress impl routes
if (/(^|\/)README\.md$/.test(urlNoFragment)) {
const targetReadmeRel = path.posix.normalize(
path.posix.join(pkgPath, urlNoFragment)
);
const route = readmeRelToDocRoute.get(targetReadmeRel);
if (route) {
const rewritten = `${route}${fragment}`;
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
}
return urlRaw;
}
// 3) Local source-file references like "config.go:29" -> GitHub blob link
if (repoUrl) {
const { filePath, line } = parseFileLineSuffix(urlNoFragment);
if (isRepoSourceFilePath(filePath)) {
const repoRel = path.posix.normalize(
path.posix.join(pkgPath, filePath)
);
const githubUrl = `${repoUrl}/blob/main/${repoRel}${
line ? `#L${line}` : ""
}`;
const rewritten = `${githubUrl}${fragment}`;
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
}
}
return urlRaw;
});
}
async function listRepoReadmes(repoRootAbs: string): Promise<string[]> {
const glob = new Glob("**/README.md");
const readmes: string[] = [];
@@ -51,8 +191,14 @@ async function listRepoReadmes(repoRootAbs: string): Promise<string[]> {
if (rel.startsWith(".git/") || rel.includes("/.git/")) continue;
if (rel.startsWith("node_modules/") || rel.includes("/node_modules/"))
continue;
if (rel.startsWith("internal/go-oidc/")) continue;
if (rel.startsWith("internal/gopsutil/")) continue;
let skip = false;
for (const submodule of skipSubmodules) {
if (rel.startsWith(submodule)) {
skip = true;
break;
}
}
if (skip) continue;
readmes.push(rel);
}
@@ -61,11 +207,34 @@ async function listRepoReadmes(repoRootAbs: string): Promise<string[]> {
return readmes;
}
async function ensureHardLink(srcAbs: string, dstAbs: string) {
async function writeImplDocCopy(params: {
srcAbs: string;
dstAbs: string;
pkgPath: string;
readmeRelToDocRoute: Map<string, string>;
dirPathToDocRoute: Map<string, string>;
repoUrl: string;
}) {
const {
srcAbs,
dstAbs,
pkgPath,
readmeRelToDocRoute,
dirPathToDocRoute,
repoUrl,
} = params;
await mkdir(path.dirname(dstAbs), { recursive: true });
await rm(dstAbs, { force: true });
// Prefer sync for better error surfaces in Bun on some platforms.
linkSync(srcAbs, dstAbs);
const original = await readFile(srcAbs, "utf8");
const rewritten = rewriteImplMarkdown({
md: original,
pkgPath,
readmeRelToDocRoute,
dirPathToDocRoute,
repoUrl,
});
await writeFile(dstAbs, rewritten);
}
async function syncImplDocs(
@@ -78,6 +247,30 @@ async function syncImplDocs(
const readmes = await listRepoReadmes(repoRootAbs);
const docs: ImplDoc[] = [];
const expectedFileNames = new Set<string>();
expectedFileNames.add("introduction.md");
const repoUrl = normalizeRepoUrl(
Bun.env.REPO_URL ?? "https://github.com/yusing/godoxy"
);
// Precompute mapping from repo-relative README path -> VitePress route.
// This lets us rewrite intra-repo README links when copying content.
const readmeRelToDocRoute = new Map<string, string>();
// Also precompute mapping from directory path -> VitePress route.
// This handles links like "[`common/`](common)" that point to directories with READMEs.
const dirPathToDocRoute = new Map<string, string>();
for (const readmeRel of readmes) {
const pkgPath = path.posix.dirname(readmeRel);
if (!pkgPath || pkgPath === ".") continue;
const docStem = sanitizeFileStemFromPkgPath(pkgPath);
if (!docStem) continue;
const route = `/impl/${docStem}`;
readmeRelToDocRoute.set(readmeRel, route);
dirPathToDocRoute.set(pkgPath, route);
}
for (const readmeRel of readmes) {
const pkgPath = path.posix.dirname(readmeRel);
@@ -86,13 +279,21 @@ async function syncImplDocs(
const docStem = sanitizeFileStemFromPkgPath(pkgPath);
if (!docStem) continue;
const docFileName = `${docStem}.md`;
const docRoute = `/impl/${docStem}`;
const srcPathAbs = path.join(repoRootAbs, readmeRel);
const dstPathAbs = path.join(implDirAbs, docFileName);
await ensureHardLink(srcPathAbs, dstPathAbs);
await writeImplDocCopy({
srcAbs: srcPathAbs,
dstAbs: dstPathAbs,
pkgPath,
readmeRelToDocRoute,
dirPathToDocRoute,
repoUrl,
});
docs.push({ pkgPath, docFileName, srcPathAbs, dstPathAbs });
docs.push({ pkgPath, docFileName, docRoute, srcPathAbs, dstPathAbs });
expectedFileNames.add(docFileName);
}
@@ -111,13 +312,13 @@ async function syncImplDocs(
}
function renderSidebarItems(docs: ImplDoc[], indent: string) {
// link: '/impl/<file>.md' because VitePress `srcDir = "src"`.
// link: '/impl/<stem>' (extensionless) because VitePress `srcDir = "src"`.
if (docs.length === 0) return "";
return (
docs
.map((d) => {
const text = escapeSingleQuotedTs(d.pkgPath);
const link = escapeSingleQuotedTs(`/impl/${d.docFileName}`);
const link = escapeSingleQuotedTs(d.docRoute);
return `${indent}{ text: '${text}', link: '${link}' },`;
})
.join("\n") + "\n"

View File

@@ -17,6 +17,6 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/puzpuzpuz/xsync/v4 v4.2.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

View File

@@ -26,8 +26,8 @@ golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=