Merge branch 'main' into dev

This commit is contained in:
yusing
2025-12-30 21:52:24 +08:00
13 changed files with 221 additions and 60 deletions

1
.gitignore vendored
View File

@@ -40,3 +40,4 @@ tsconfig.tsbuildinfo
!agent.compose.yml
!agent/pkg/**
dev-data/

View File

@@ -148,6 +148,7 @@ push-github:
git push origin $(shell git rev-parse --abbrev-ref HEAD)
gen-swagger:
# go install github.com/swaggo/swag/cmd/swag@latest
swag init --parseDependency --parseInternal --parseFuncBody -g handler.go -d internal/api -o internal/api/v1/docs
python3 scripts/fix-swagger-json.py
# we don't need this

View File

@@ -2458,8 +2458,8 @@
"x-nullable": false,
"x-omitempty": false
},
"docker_host": {
"type": "string",
"docker_cfg": {
"$ref": "#/definitions/DockerProviderConfig",
"x-nullable": false,
"x-omitempty": false
},
@@ -2715,7 +2715,7 @@
"required": [
"container_id",
"container_name",
"docker_host"
"docker_cfg"
],
"properties": {
"container_id": {
@@ -2728,7 +2728,24 @@
"x-nullable": false,
"x-omitempty": false
},
"docker_host": {
"docker_cfg": {
"$ref": "#/definitions/DockerProviderConfig",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"DockerProviderConfig": {
"type": "object",
"properties": {
"tls": {
"$ref": "#/definitions/DockerTLSConfig",
"x-nullable": false,
"x-omitempty": false
},
"url": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
@@ -2737,6 +2754,27 @@
"x-nullable": false,
"x-omitempty": false
},
"DockerTLSConfig": {
"type": "object",
"required": [
"ca_file"
],
"properties": {
"ca_file": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"cert_file": {
"type": "string"
},
"key_file": {
"type": "string"
}
},
"x-nullable": false,
"x-omitempty": false
},
"ErrorResponse": {
"type": "object",
"properties": {
@@ -3430,6 +3468,11 @@
"x-nullable": false,
"x-omitempty": false
},
"no_loading_page": {
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"proxmox": {
"$ref": "#/definitions/ProxmoxConfig",
"x-nullable": false,
@@ -4234,6 +4277,12 @@
],
"x-nullable": true
},
"index": {
"description": "Index file to serve for single-page app mode",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"load_balance": {
"allOf": [
{
@@ -4315,6 +4364,12 @@
"x-nullable": false,
"x-omitempty": false
},
"spa": {
"description": "Single-page app mode: serves index for non-existent paths",
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate": {
"description": "Path to client certificate",
"type": "string",
@@ -5354,6 +5409,12 @@
],
"x-nullable": true
},
"index": {
"description": "Index file to serve for single-page app mode",
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"load_balance": {
"allOf": [
{
@@ -5435,6 +5496,12 @@
"x-nullable": false,
"x-omitempty": false
},
"spa": {
"description": "Single-page app mode: serves index for non-existent paths",
"type": "boolean",
"x-nullable": false,
"x-omitempty": false
},
"ssl_certificate": {
"description": "Path to client certificate",
"type": "string",

View File

@@ -57,8 +57,8 @@ definitions:
type: string
container_name:
type: string
docker_host:
type: string
docker_cfg:
$ref: '#/definitions/DockerProviderConfig'
errors:
type: string
idlewatcher_config:
@@ -192,12 +192,30 @@ definitions:
type: string
container_name:
type: string
docker_host:
type: string
docker_cfg:
$ref: '#/definitions/DockerProviderConfig'
required:
- container_id
- container_name
- docker_host
- docker_cfg
type: object
DockerProviderConfig:
properties:
tls:
$ref: '#/definitions/DockerTLSConfig'
url:
type: string
type: object
DockerTLSConfig:
properties:
ca_file:
type: string
cert_file:
type: string
key_file:
type: string
required:
- ca_file
type: object
ErrorResponse:
properties:
@@ -517,6 +535,8 @@ definitions:
description: "0: no idle watcher.\nPositive: idle watcher with idle timeout.\nNegative:
idle watcher as a dependency.\tIdleTimeout time.Duration `json:\"idle_timeout\"
json_ext:\"duration\"`"
no_loading_page:
type: boolean
proxmox:
$ref: '#/definitions/ProxmoxConfig'
start_endpoint:
@@ -897,6 +917,9 @@ definitions:
allOf:
- $ref: '#/definitions/IdlewatcherConfig'
x-nullable: true
index:
description: Index file to serve for single-page app mode
type: string
load_balance:
allOf:
- $ref: '#/definitions/LoadBalancerConfig'
@@ -944,6 +967,9 @@ definitions:
- udp
- fileserver
type: string
spa:
description: 'Single-page app mode: serves index for non-existent paths'
type: boolean
ssl_certificate:
description: Path to client certificate
type: string
@@ -1504,6 +1530,9 @@ definitions:
allOf:
- $ref: '#/definitions/IdlewatcherConfig'
x-nullable: true
index:
description: Index file to serve for single-page app mode
type: string
load_balance:
allOf:
- $ref: '#/definitions/LoadBalancerConfig'
@@ -1551,6 +1580,9 @@ definitions:
- udp
- fileserver
type: string
spa:
description: 'Single-page app mode: serves index for non-existent paths'
type: boolean
ssl_certificate:
description: Path to client certificate
type: string

View File

@@ -34,12 +34,12 @@ func Routes(c *gin.Context) {
provider := c.Query("provider")
if provider == "" {
c.JSON(http.StatusOK, slices.Collect(routes.Iter))
c.JSON(http.StatusOK, slices.Collect(routes.IterAll))
return
}
rts := make([]types.Route, 0, routes.NumRoutes())
for r := range routes.Iter {
rts := make([]types.Route, 0, routes.NumAllRoutes())
for r := range routes.IterAll {
if r.ProviderName() == provider {
rts = append(rts, r)
}
@@ -51,14 +51,14 @@ func RoutesWS(c *gin.Context) {
provider := c.Query("provider")
if provider == "" {
websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) {
return slices.Collect(routes.Iter), nil
return slices.Collect(routes.IterAll), nil
})
return
}
websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) {
rts := make([]types.Route, 0, routes.NumRoutes())
for r := range routes.Iter {
rts := make([]types.Route, 0, routes.NumAllRoutes())
for r := range routes.IterAll {
if r.ProviderName() == provider {
rts = append(rts, r)
}

View File

@@ -2,7 +2,6 @@ package docker
import (
"context"
"errors"
"fmt"
"maps"
"net"
@@ -17,7 +16,6 @@ import (
"github.com/moby/moby/client"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/types"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/task"
@@ -118,7 +116,7 @@ func Clients() map[string]*SharedClient {
// Returns existing client if available.
//
// Parameters:
// - host: the host to connect to (either a URL or common.DockerHostFromEnv).
// - host: the host to connect to (either a URL or client.DefaultDockerHost).
//
// Returns:
// - Client: the Docker client connection.
@@ -161,27 +159,18 @@ func NewClient(cfg types.DockerProviderConfig, unique ...bool) (*SharedClient, e
addr = "tcp://" + cfg.Addr
dial = cfg.DialContext
} else {
switch host {
case "":
return nil, errors.New("empty docker host")
case common.DockerHostFromEnv:
helper, err := connhelper.GetConnectionHelper(host)
if err != nil {
log.Panic().Err(err).Msg("failed to get connection helper")
}
if helper != nil {
opt = []client.Opt{
client.WithHostFromEnv(),
client.WithHost(helper.Host),
client.WithDialContext(helper.Dialer),
}
default:
helper, err := connhelper.GetConnectionHelper(host)
if err != nil {
log.Panic().Err(err).Msg("failed to get connection helper")
}
if helper != nil {
opt = []client.Opt{
client.WithHost(helper.Host),
client.WithDialContext(helper.Dialer),
}
} else {
opt = []client.Opt{
client.WithHost(host),
}
} else {
opt = []client.Opt{
client.WithHost(host),
}
}
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/bytedance/sonic"
"github.com/lithammer/fuzzysearch/fuzzy"
statequery "github.com/yusing/godoxy/internal/config/query"
"github.com/yusing/godoxy/internal/metrics/period"
metricsutils "github.com/yusing/godoxy/internal/metrics/utils"
"github.com/yusing/godoxy/internal/route/routes"
@@ -133,7 +132,7 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated {
r, ok := routes.Get(alias)
if !ok {
// also search for excluded routes
r = statequery.SearchRoute(alias)
r, ok = routes.Excluded.Get(alias)
}
if r != nil {
displayName = r.DisplayName()

View File

@@ -8,17 +8,14 @@ import (
"sync"
"time"
"github.com/moby/moby/client"
"github.com/rs/zerolog"
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/route"
provider "github.com/yusing/godoxy/internal/route/provider/types"
"github.com/yusing/godoxy/internal/types"
W "github.com/yusing/godoxy/internal/watcher"
"github.com/yusing/godoxy/internal/watcher/events"
"github.com/yusing/goutils/env"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/task"
)
@@ -70,10 +67,6 @@ func NewFileProvider(filename string) (p *Provider, err error) {
}
func NewDockerProvider(name string, dockerCfg types.DockerProviderConfig) *Provider {
if dockerCfg.URL == common.DockerHostFromEnv {
dockerCfg.URL = env.GetEnvString("DOCKER_HOST", client.DefaultDockerHost)
}
p := newProvider(provider.ProviderTypeDocker)
p.ProviderImpl = DockerProviderImpl(name, dockerCfg)
p.watcher = p.NewWatcher()

View File

@@ -24,12 +24,14 @@ import (
"github.com/yusing/godoxy/internal/proxmox"
"github.com/yusing/godoxy/internal/serialization"
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/godoxy/internal/watcher/health/monitor"
gperr "github.com/yusing/goutils/errs"
strutils "github.com/yusing/goutils/strings"
"github.com/yusing/goutils/task"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/logging/accesslog"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/route/rules"
rulepresets "github.com/yusing/godoxy/internal/route/rules/presets"
route "github.com/yusing/godoxy/internal/route/types"
@@ -397,8 +399,17 @@ func (r *Route) start(parent task.Parent) gperr.Error {
if err := r.impl.Start(parent); err != nil {
return err
}
} else { // required by idlewatcher
r.task = parent.Subtask("excluded."+r.Name(), false)
} else {
r.task = parent.Subtask("excluded."+r.Name(), true)
routes.Excluded.Add(r.impl)
r.task.OnCancel("remove_route_from_excluded", func() {
routes.Excluded.Del(r.impl)
})
if r.UseHealthCheck() {
r.HealthMon = monitor.NewMonitor(r)
err := r.HealthMon.Start(r.task)
return err
}
}
return nil
}

View File

@@ -17,17 +17,23 @@ type HealthInfoWithoutDetail struct {
Latency time.Duration `json:"latency" swaggertype:"number"` // latency in microseconds
} // @name HealthInfoWithoutDetail
// GetHealthInfo returns a map of route name to health info.
//
// The health info is for all routes, including excluded routes.
func GetHealthInfo() map[string]HealthInfo {
healthMap := make(map[string]HealthInfo, NumRoutes())
for r := range Iter {
healthMap := make(map[string]HealthInfo, NumAllRoutes())
for r := range IterAll {
healthMap[r.Name()] = getHealthInfo(r)
}
return healthMap
}
// GetHealthInfoWithoutDetail returns a map of route name to health info without detail.
//
// The health info is for all routes, including excluded routes.
func GetHealthInfoWithoutDetail() map[string]HealthInfoWithoutDetail {
healthMap := make(map[string]HealthInfoWithoutDetail, NumRoutes())
for r := range Iter {
healthMap := make(map[string]HealthInfoWithoutDetail, NumAllRoutes())
for r := range IterAll {
healthMap[r.Name()] = getHealthInfoWithoutDetail(r)
}
return healthMap
@@ -67,9 +73,12 @@ func getHealthInfoWithoutDetail(r types.Route) HealthInfoWithoutDetail {
}
}
// ByProvider returns a map of provider name to routes.
//
// The routes are all routes, including excluded routes.
func ByProvider() map[string][]types.Route {
rts := make(map[string][]types.Route)
for r := range Iter {
for r := range IterAll {
rts[r.ProviderName()] = append(rts[r.ProviderName()], r)
}
return rts

View File

@@ -8,9 +8,11 @@ import (
var (
HTTP = pool.New[types.HTTPRoute]("http_routes")
Stream = pool.New[types.StreamRoute]("stream_routes")
Excluded = pool.New[types.Route]("excluded_routes")
)
func Iter(yield func(r types.Route) bool) {
func IterActive(yield func(r types.Route) bool) {
for _, r := range HTTP.Iter {
if !yield(r) {
break
@@ -23,26 +25,36 @@ func Iter(yield func(r types.Route) bool) {
}
}
func IterKV(yield func(alias string, r types.Route) bool) {
for k, r := range HTTP.Iter {
if !yield(k, r) {
func IterAll(yield func(r types.Route) bool) {
for _, r := range HTTP.Iter {
if !yield(r) {
break
}
}
for k, r := range Stream.Iter {
if !yield(k, r) {
for _, r := range Stream.Iter {
if !yield(r) {
break
}
}
for _, r := range Excluded.Iter {
if !yield(r) {
break
}
}
}
func NumRoutes() int {
func NumActiveRoutes() int {
return HTTP.Size() + Stream.Size()
}
func NumAllRoutes() int {
return HTTP.Size() + Stream.Size() + Excluded.Size()
}
func Clear() {
HTTP.Clear()
Stream.Clear()
Excluded.Clear()
}
func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) {
@@ -54,6 +66,9 @@ func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) {
return HTTP.Get(host)
}
// Get returns the route with the given alias.
//
// It does not return excluded routes.
func Get(alias string) (types.Route, bool) {
if r, ok := HTTP.Get(alias); ok {
return r, true

View File

@@ -15,6 +15,7 @@ import (
nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/godoxy/internal/notif"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/http/reverseproxy"
@@ -38,6 +39,7 @@ const (
CommandServe = "serve"
CommandProxy = "proxy"
CommandRedirect = "redirect"
CommandRoute = "route"
CommandError = "error"
CommandRequireBasicAuth = "require_basic_auth"
CommandSet = "set"
@@ -171,6 +173,42 @@ var commands = map[string]struct {
})
},
},
CommandRoute: {
help: Help{
command: CommandRoute,
description: makeLines(
"Route the request to another route, e.g.:",
helpExample(CommandRoute, "route1"),
),
args: map[string]string{
"route": "the route to route to",
},
},
validate: func(args []string) (any, gperr.Error) {
if len(args) != 1 {
return nil, ErrExpectOneArg
}
return args[0], nil
},
build: func(args any) CommandHandler {
route := args.(string)
return TerminatingCommand(func(w http.ResponseWriter, req *http.Request) error {
r, ok := routes.HTTP.Get(route)
if !ok {
excluded, has := routes.Excluded.Get(route)
if has {
r, ok = excluded.(types.HTTPRoute)
}
}
if ok {
r.ServeHTTP(w, req)
} else {
http.Error(w, fmt.Sprintf("Route %q not found", route), http.StatusNotFound)
}
return nil
})
},
},
CommandError: {
help: Help{
command: CommandError,

View File

@@ -10,6 +10,7 @@ import (
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/serialization"
"github.com/yusing/goutils/env"
gperr "github.com/yusing/goutils/errs"
)
@@ -36,6 +37,11 @@ func (cfg *DockerProviderConfig) MarshalJSON() ([]byte, error) {
}
func (cfg *DockerProviderConfig) Parse(value string) error {
if value == common.DockerHostFromEnv {
cfg.URL = env.GetEnvString("DOCKER_HOST", "unix:///var/run/docker.sock")
return nil
}
u, err := url.Parse(value)
if err != nil {
return err