refactor(api): restructured API for type safety, maintainability and docs generation

- These changes makes the API incombatible with previous versions
- Added new types for error handling, success responses, and health checks.
- Updated health check logic to utilize the new types for better clarity and structure.
- Refactored existing handlers to improve response consistency and error handling.
- Updated Makefile to include a new target for generating API types from Swagger.
- Updated "new agent" API to respond an encrypted cert pair
This commit is contained in:
yusing
2025-08-16 13:04:05 +08:00
parent fce9ce21c9
commit 35a3e3fef6
149 changed files with 13173 additions and 2173 deletions

77
internal/types/docker.go Normal file
View File

@@ -0,0 +1,77 @@
package types
import (
"encoding/json"
"github.com/docker/docker/api/types/container"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/utils"
)
type (
LabelMap = map[string]any
PortMapping = map[int]container.Port
Container struct {
_ utils.NoCopy
DockerHost string `json:"docker_host"`
Image *ContainerImage `json:"image"`
ContainerName string `json:"container_name"`
ContainerID string `json:"container_id"`
Agent *agent.AgentConfig `json:"agent"`
Labels map[string]string `json:"-"`
IdlewatcherConfig *IdlewatcherConfig `json:"idlewatcher_config"`
Mounts []string `json:"mounts"`
Network string `json:"network,omitempty"`
PublicPortMapping PortMapping `json:"public_ports"` // non-zero publicPort:types.Port
PrivatePortMapping PortMapping `json:"private_ports"` // privatePort:types.Port
PublicHostname string `json:"public_hostname"`
PrivateHostname string `json:"private_hostname"`
Aliases []string `json:"aliases"`
IsExcluded bool `json:"is_excluded"`
IsExplicit bool `json:"is_explicit"`
IsHostNetworkMode bool `json:"is_host_network_mode"`
Running bool `json:"running"`
Errors *ContainerError `json:"errors" swaggertype:"string"`
} // @name Container
ContainerImage struct {
Author string `json:"author,omitempty"`
Name string `json:"name"`
Tag string `json:"tag,omitempty"`
} // @name ContainerImage
ContainerError struct {
errs *gperr.Builder
}
)
func (e *ContainerError) Add(err error) {
if e.errs == nil {
e.errs = gperr.NewBuilder()
}
e.errs.Add(err)
}
func (e *ContainerError) Error() string {
if e.errs == nil {
return "<niL>"
}
return e.errs.String()
}
func (e *ContainerError) Unwrap() error {
return e.errs.Error()
}
func (e *ContainerError) MarshalJSON() ([]byte, error) {
err := e.errs.Error().(interface{ Plain() []byte })
return json.Marshal(string(err.Plain()))
}

166
internal/types/health.go Normal file
View File

@@ -0,0 +1,166 @@
package types
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type (
HealthStatus uint8
HealthCheckResult struct {
Healthy bool `json:"healthy"`
Detail string `json:"detail"`
Latency time.Duration `json:"latency"`
} // @name HealthCheckResult
WithHealthInfo interface {
Status() HealthStatus
Uptime() time.Duration
Latency() time.Duration
Detail() string
}
HealthMonitor interface {
task.TaskStarter
task.TaskFinisher
fmt.Stringer
WithHealthInfo
Name() string
json.Marshaler
}
HealthChecker interface {
CheckHealth() (result *HealthCheckResult, err error)
URL() *url.URL
Config() *HealthCheckConfig
UpdateURL(url *url.URL)
}
HealthMonCheck interface {
HealthMonitor
HealthChecker
}
HealthJSON struct {
Name string `json:"name"`
Config *HealthCheckConfig `json:"config"`
Started int64 `json:"started"`
StartedStr string `json:"startedStr"`
Status string `json:"status"`
Uptime float64 `json:"uptime"`
UptimeStr string `json:"uptimeStr"`
Latency float64 `json:"latency"`
LatencyStr string `json:"latencyStr"`
LastSeen int64 `json:"lastSeen"`
LastSeenStr string `json:"lastSeenStr"`
Detail string `json:"detail"`
URL string `json:"url"`
Extra *HealthExtra `json:"extra" extensions:"x-nullable"`
} // @name HealthJSON
HealthJSONRepr struct {
Name string
Config *HealthCheckConfig
Status HealthStatus
Started time.Time
Uptime time.Duration
Latency time.Duration
LastSeen time.Time
Detail string
URL *url.URL
Extra *HealthExtra
}
HealthExtra struct {
Config *LoadBalancerConfig `json:"config"`
Pool map[string]any `json:"pool"`
} // @name HealthExtra
)
const (
StatusUnknown HealthStatus = 0
StatusHealthy HealthStatus = (1 << iota)
StatusNapping
StatusStarting
StatusUnhealthy
StatusError
NumStatuses int = iota - 1
HealthyMask = StatusHealthy | StatusNapping | StatusStarting
IdlingMask = StatusNapping | StatusStarting
)
func NewHealthStatusFromString(s string) HealthStatus {
switch s {
case "healthy":
return StatusHealthy
case "unhealthy":
return StatusUnhealthy
case "napping":
return StatusNapping
case "starting":
return StatusStarting
case "error":
return StatusError
default:
return StatusUnknown
}
}
func (s HealthStatus) String() string {
switch s {
case StatusHealthy:
return "healthy"
case StatusUnhealthy:
return "unhealthy"
case StatusNapping:
return "napping"
case StatusStarting:
return "starting"
case StatusError:
return "error"
default:
return "unknown"
}
}
func (s HealthStatus) Good() bool {
return s&HealthyMask != 0
}
func (s HealthStatus) Bad() bool {
return s&HealthyMask == 0
}
func (s HealthStatus) Idling() bool {
return s&IdlingMask != 0
}
func (jsonRepr *HealthJSONRepr) MarshalJSON() ([]byte, error) {
var url string
if jsonRepr.URL != nil {
url = jsonRepr.URL.String()
}
if url == "http://:0" {
url = ""
}
return json.Marshal(HealthJSON{
Name: jsonRepr.Name,
Config: jsonRepr.Config,
Started: jsonRepr.Started.Unix(),
StartedStr: strutils.FormatTime(jsonRepr.Started),
Status: jsonRepr.Status.String(),
Uptime: jsonRepr.Uptime.Seconds(),
UptimeStr: strutils.FormatDuration(jsonRepr.Uptime),
Latency: jsonRepr.Latency.Seconds(),
LatencyStr: strconv.Itoa(int(jsonRepr.Latency.Milliseconds())) + " ms",
LastSeen: jsonRepr.LastSeen.Unix(),
LastSeenStr: strutils.FormatLastSeen(jsonRepr.LastSeen),
Detail: jsonRepr.Detail,
URL: url,
Extra: jsonRepr.Extra,
})
}

View File

@@ -0,0 +1,27 @@
package types
import (
"context"
"time"
"github.com/yusing/go-proxy/internal/common"
)
type HealthCheckConfig struct {
Disable bool `json:"disable,omitempty" aliases:"disabled"`
Path string `json:"path,omitempty" validate:"omitempty,uri,startswith=/"`
UseGet bool `json:"use_get,omitempty"`
Interval time.Duration `json:"interval" validate:"omitempty,min=1s" swaggertype:"primitive,integer"`
Timeout time.Duration `json:"timeout" validate:"omitempty,min=1s" swaggertype:"primitive,integer"`
Retries int64 `json:"retries"` // <0: immediate, >=0: threshold
BaseContext func() context.Context `json:"-"`
} // @name HealthCheckConfig
func DefaultHealthConfig() *HealthCheckConfig {
return &HealthCheckConfig{
Interval: common.HealthCheckIntervalDefault,
Timeout: common.HealthCheckTimeoutDefault,
Retries: int64(common.HealthCheckDownNotifyDelayDefault / common.HealthCheckIntervalDefault),
}
}

View File

@@ -0,0 +1,138 @@
package types
import (
"net/url"
"strconv"
"strings"
"time"
"github.com/yusing/go-proxy/internal/gperr"
)
type (
IdlewatcherProviderConfig struct {
Proxmox *ProxmoxConfig `json:"proxmox,omitempty"`
Docker *DockerConfig `json:"docker,omitempty"`
} // @name IdlewatcherProviderConfig
IdlewatcherConfigBase struct {
// 0: no idle watcher.
// Positive: idle watcher with idle timeout.
// Negative: idle watcher as a dependency. IdleTimeout time.Duration `json:"idle_timeout" json_ext:"duration"`
IdleTimeout time.Duration `json:"idle_timeout"`
WakeTimeout time.Duration `json:"wake_timeout"`
StopTimeout time.Duration `json:"stop_timeout"`
StopMethod ContainerStopMethod `json:"stop_method"`
StopSignal ContainerSignal `json:"stop_signal,omitempty"`
} // @name IdlewatcherConfigBase
IdlewatcherConfig struct {
IdlewatcherProviderConfig
IdlewatcherConfigBase
StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container
DependsOn []string `json:"depends_on,omitempty"`
} // @name IdlewatcherConfig
ContainerStopMethod string // @name ContainerStopMethod
ContainerSignal string // @name ContainerSignal
DockerConfig struct {
DockerHost string `json:"docker_host" validate:"required"`
ContainerID string `json:"container_id" validate:"required"`
ContainerName string `json:"container_name" validate:"required"`
} // @name DockerConfig
ProxmoxConfig struct {
Node string `json:"node" validate:"required"`
VMID int `json:"vmid" validate:"required"`
} // @name ProxmoxConfig
)
const (
ContainerWakeTimeoutDefault = 30 * time.Second
ContainerStopTimeoutDefault = 1 * time.Minute
ContainerStopMethodPause ContainerStopMethod = "pause"
ContainerStopMethodStop ContainerStopMethod = "stop"
ContainerStopMethodKill ContainerStopMethod = "kill"
)
func (c *IdlewatcherConfig) Key() string {
if c.Docker != nil {
return c.Docker.ContainerID
}
return c.Proxmox.Node + ":" + strconv.Itoa(c.Proxmox.VMID)
}
func (c *IdlewatcherConfig) ContainerName() string {
if c.Docker != nil {
return c.Docker.ContainerName
}
return "lxc-" + strconv.Itoa(c.Proxmox.VMID)
}
func (c *IdlewatcherConfig) Validate() gperr.Error {
if c.IdleTimeout == 0 { // zero idle timeout means no idle watcher
return nil
}
errs := gperr.NewBuilder("idlewatcher config validation error")
errs.AddRange(
c.validateProvider(),
c.validateTimeouts(),
c.validateStopMethod(),
c.validateStopSignal(),
c.validateStartEndpoint(),
)
return errs.Error()
}
func (c *IdlewatcherConfig) validateProvider() error {
if c.Docker == nil && c.Proxmox == nil {
return gperr.New("missing idlewatcher provider config")
}
return nil
}
func (c *IdlewatcherConfig) validateTimeouts() error { //nolint:unparam
if c.WakeTimeout == 0 {
c.WakeTimeout = ContainerWakeTimeoutDefault
}
if c.StopTimeout == 0 {
c.StopTimeout = ContainerStopTimeoutDefault
}
return nil
}
func (c *IdlewatcherConfig) validateStopMethod() error {
switch c.StopMethod {
case "":
c.StopMethod = ContainerStopMethodStop
return nil
case ContainerStopMethodPause, ContainerStopMethodStop, ContainerStopMethodKill:
return nil
default:
return gperr.New("invalid stop method").Subject(string(c.StopMethod))
}
}
func (c *IdlewatcherConfig) validateStopSignal() error {
switch c.StopSignal {
case "", "SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP", "INT", "TERM", "QUIT", "HUP":
return nil
default:
return gperr.New("invalid stop signal").Subject(string(c.StopSignal))
}
}
func (c *IdlewatcherConfig) validateStartEndpoint() error {
if c.StartEndpoint == "" {
return nil
}
// checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195
// emulate browser and strip the '#' suffix prior to validation. see issue-#237
if i := strings.Index(c.StartEndpoint, "#"); i > -1 {
c.StartEndpoint = c.StartEndpoint[:i]
}
if len(c.StartEndpoint) == 0 {
return gperr.New("start endpoint must not be empty if defined")
}
_, err := url.ParseRequestURI(c.StartEndpoint)
return err
}

View File

@@ -0,0 +1,49 @@
package types
import (
"testing"
expect "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestValidateStartEndpoint(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{
name: "valid",
input: "/start",
wantErr: false,
},
{
name: "invalid",
input: "../foo",
wantErr: true,
},
{
name: "single fragment",
input: "#",
wantErr: true,
},
{
name: "empty",
input: "",
wantErr: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := new(IdlewatcherConfig)
cfg.StartEndpoint = tc.input
err := cfg.validateStartEndpoint()
if err == nil {
expect.Equal(t, cfg.StartEndpoint, tc.input)
}
if (err != nil) != tc.wantErr {
t.Errorf("validateStartEndpoint() error = %v, wantErr %t", err, tc.wantErr)
}
})
}
}

View File

@@ -0,0 +1,54 @@
package types
import (
"net/http"
nettypes "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type (
LoadBalancerConfig struct {
Link string `json:"link"`
Mode LoadBalancerMode `json:"mode"`
Weight int `json:"weight"`
Options map[string]any `json:"options,omitempty"`
} // @name LoadBalancerConfig
LoadBalancerMode string // @name LoadBalancerMode
LoadBalancerServer interface {
http.Handler
HealthMonitor
Name() string
Key() string
URL() *nettypes.URL
Weight() int
SetWeight(weight int)
TryWake() error
}
LoadBalancerServers []LoadBalancerServer
)
const (
LoadbalanceModeUnset LoadBalancerMode = ""
LoadbalanceModeRoundRobin LoadBalancerMode = "roundrobin"
LoadbalanceModeLeastConn LoadBalancerMode = "leastconn"
LoadbalanceModeIPHash LoadBalancerMode = "iphash"
)
func (mode *LoadBalancerMode) ValidateUpdate() bool {
switch strutils.ToLowerNoSnake(string(*mode)) {
case "":
return true
case string(LoadbalanceModeRoundRobin):
*mode = LoadbalanceModeRoundRobin
return true
case string(LoadbalanceModeLeastConn):
*mode = LoadbalanceModeLeastConn
return true
case string(LoadbalanceModeIPHash):
*mode = LoadbalanceModeIPHash
return true
}
*mode = LoadbalanceModeRoundRobin
return false
}

63
internal/types/routes.go Normal file
View File

@@ -0,0 +1,63 @@
package types
import (
"net/http"
"github.com/yusing/go-proxy/agent/pkg/agent"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
nettypes "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/utils/pool"
)
type (
Route interface {
task.TaskStarter
task.TaskFinisher
pool.Object
ProviderName() string
GetProvider() RouteProvider
TargetURL() *nettypes.URL
HealthMonitor() HealthMonitor
SetHealthMonitor(m HealthMonitor)
References() []string
Started() <-chan struct{}
IdlewatcherConfig() *IdlewatcherConfig
HealthCheckConfig() *HealthCheckConfig
LoadBalanceConfig() *LoadBalancerConfig
HomepageConfig() *homepage.ItemConfig
HomepageItem() *homepage.Item
ContainerInfo() *Container
GetAgent() *agent.AgentConfig
IsDocker() bool
IsAgent() bool
UseLoadBalance() bool
UseIdleWatcher() bool
UseHealthCheck() bool
UseAccessLog() bool
}
HTTPRoute interface {
Route
http.Handler
}
ReverseProxyRoute interface {
HTTPRoute
ReverseProxy() *reverseproxy.ReverseProxy
}
StreamRoute interface {
Route
nettypes.Stream
Stream() nettypes.Stream
}
RouteProvider interface {
GetRoute(alias string) (r Route, ok bool)
IterRoutes(yield func(alias string, r Route) bool)
FindService(project, service string) (r Route, ok bool)
ShortName() string
}
)

50
internal/types/stats.go Normal file
View File

@@ -0,0 +1,50 @@
package types
import provider "github.com/yusing/go-proxy/internal/route/provider/types"
type (
RouteStats struct {
Total uint16 `json:"total"`
NumHealthy uint16 `json:"healthy"`
NumUnhealthy uint16 `json:"unhealthy"`
NumNapping uint16 `json:"napping"`
NumError uint16 `json:"error"`
NumUnknown uint16 `json:"unknown"`
} // @name RouteStats
ProviderStats struct {
Total uint16 `json:"total"`
RPs RouteStats `json:"reverse_proxies"`
Streams RouteStats `json:"streams"`
Type provider.Type `json:"type"`
} // @name ProviderStats
)
func (stats *RouteStats) Add(r Route) {
stats.Total++
mon := r.HealthMonitor()
if mon == nil {
stats.NumUnknown++
return
}
switch mon.Status() {
case StatusHealthy:
stats.NumHealthy++
case StatusUnhealthy:
stats.NumUnhealthy++
case StatusNapping:
stats.NumNapping++
case StatusError:
stats.NumError++
default:
stats.NumUnknown++
}
}
func (stats *RouteStats) AddOther(other RouteStats) {
stats.Total += other.Total
stats.NumHealthy += other.NumHealthy
stats.NumUnhealthy += other.NumUnhealthy
stats.NumNapping += other.NumNapping
stats.NumError += other.NumError
stats.NumUnknown += other.NumUnknown
}