Feat/fileserver (#60)

* cleanup code for URL type

* fix makefile for trace mode

* refactor, merge Entry, RawEntry and Route into one. 

* Implement fileserver.

* refactor: rename HTTPRoute to ReverseProxyRoute to avoid confusion

* refactor: move metrics logger to middleware package

- fix prometheus metrics for load balanced routes
  - route will now fail when health monitor fail to start

* fix extra output of ls-* commands by defer initializaing stuff, speed up start time

* add test for path traversal attack, small fix on FileServer.Start method

* rename rule.on.bypass to pass

* refactor and fixed map-to-map  deserialization

* updated route loading logic

* schemas: add "add_prefix" option to modify_request middleware


* updated route JSONMarshalling

---------

Co-authored-by: yusing <yusing@6uo.me>
This commit is contained in:
Yuzerion
2025-02-06 18:23:10 +08:00
committed by GitHub
parent 4d47eb0e91
commit 1a5f3735cf
79 changed files with 1484 additions and 1276 deletions

View File

@@ -1,13 +0,0 @@
package types
import (
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
net "github.com/yusing/go-proxy/internal/net/types"
)
type Entry interface {
TargetName() string
TargetURL() net.URL
RawEntry() *RawEntry
IdlewatcherConfig() *idlewatcher.Config
}

View File

@@ -1,19 +0,0 @@
package types
import (
"net/http"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
func ValidateHTTPHeaders(headers map[string]string) (http.Header, E.Error) {
h := make(http.Header)
for k, v := range headers {
vSplit := strutils.CommaSeperatedList(v)
for _, header := range vSplit {
h.Add(k, header)
}
}
return h, nil
}

View File

@@ -1,9 +1,11 @@
package types
package types_test
import (
"testing"
"time"
. "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
@@ -12,14 +14,14 @@ func TestHTTPConfigDeserialize(t *testing.T) {
tests := []struct {
name string
input map[string]any
expected HTTPConfig
expected types.HTTPConfig
}{
{
name: "no_tls_verify",
input: map[string]any{
"no_tls_verify": "true",
},
expected: HTTPConfig{
expected: types.HTTPConfig{
NoTLSVerify: true,
},
},
@@ -28,7 +30,7 @@ func TestHTTPConfigDeserialize(t *testing.T) {
input: map[string]any{
"response_header_timeout": "1s",
},
expected: HTTPConfig{
expected: types.HTTPConfig{
ResponseHeaderTimeout: 1 * time.Second,
},
},
@@ -36,7 +38,7 @@ func TestHTTPConfigDeserialize(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := RawEntry{}
cfg := Route{}
err := utils.Deserialize(tt.input, &cfg)
if err != nil {
ExpectNoError(t, err)

View File

@@ -7,37 +7,55 @@ import (
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type Port int
type Port struct {
Listening int `json:"listening"`
Proxy int `json:"proxy"`
}
var ErrPortOutOfRange = E.New("port out of range")
var (
ErrInvalidPortSyntax = E.New("invalid port syntax, expect [listening_port:]target_port")
ErrPortOutOfRange = E.New("port out of range")
)
// Parse implements strutils.Parser.
func (p *Port) Parse(v string) (err error) {
parts := strutils.SplitRune(v, ':')
switch len(parts) {
case 1:
p.Listening = 0
p.Proxy, err = strconv.Atoi(v)
case 2:
var err2 error
p.Listening, err = strconv.Atoi(parts[0])
p.Proxy, err2 = strconv.Atoi(parts[1])
err = E.Join(err, err2)
default:
return ErrInvalidPortSyntax.Subject(v)
}
func ValidatePort[String ~string](v String) (Port, error) {
p, err := strutils.Atoi(string(v))
if err != nil {
return ErrPort, err
return err
}
return ValidatePortInt(p)
}
func ValidatePortInt[Int int | uint16](v Int) (Port, error) {
p := Port(v)
if !p.inBound() {
return ErrPort, ErrPortOutOfRange.Subject(strconv.Itoa(int(p)))
if p.Listening < MinPort || p.Listening > MaxPort {
return ErrPortOutOfRange.Subjectf("%d", p.Listening)
}
return p, nil
if p.Proxy < MinPort || p.Proxy > MaxPort {
return ErrPortOutOfRange.Subjectf("%d", p.Proxy)
}
return nil
}
func (p Port) inBound() bool {
return p >= MinPort && p <= MaxPort
}
func (p Port) String() string {
return strconv.Itoa(int(p))
func (p *Port) String() string {
if p.Listening == 0 {
return strconv.Itoa(p.Proxy)
}
return strconv.Itoa(p.Listening) + ":" + strconv.Itoa(p.Proxy)
}
const (
MinPort = 0
MaxPort = 65535
ErrPort = Port(-1)
NoPort = Port(0)
)

View File

@@ -0,0 +1,106 @@
package types
import (
"errors"
"strconv"
"testing"
)
var invalidPorts = []string{
"",
"123:",
"0:",
":1234",
"qwerty",
"asdfgh:asdfgh",
"1234:asdfgh",
}
var tooManyColonsPorts = []string{
"1234:1234:1234",
}
var outOfRangePorts = []string{
"-1:1234",
"1234:-1",
"65536",
"0:65536",
}
func TestPortInvalid(t *testing.T) {
tests := []struct {
name string
inputs []string
wantErr error
}{
{
name: "invalid",
inputs: invalidPorts,
wantErr: strconv.ErrSyntax,
},
{
name: "too many colons",
inputs: tooManyColonsPorts,
wantErr: ErrInvalidPortSyntax,
},
{
name: "out of range",
inputs: outOfRangePorts,
wantErr: ErrPortOutOfRange,
},
}
for _, tc := range tests {
for _, input := range tc.inputs {
t.Run(tc.name, func(t *testing.T) {
p := &Port{}
err := p.Parse(input)
if !errors.Is(err, tc.wantErr) {
t.Errorf("expected error %v, got %v", tc.wantErr, err)
}
})
}
}
}
func TestPortValid(t *testing.T) {
tests := []struct {
name string
inputs string
expect Port
}{
{
name: "valid_lp",
inputs: "1234:5678",
expect: Port{
Listening: 1234,
Proxy: 5678,
},
},
{
name: "valid_p",
inputs: "5678",
expect: Port{
Listening: 0,
Proxy: 5678,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := &Port{}
err := p.Parse(tc.inputs)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if p.Listening != tc.expect.Listening {
t.Errorf("expected listening port %d, got %d", tc.expect.Listening, p.Listening)
}
if p.Proxy != tc.expect.Proxy {
t.Errorf("expected proxy port %d, got %d", tc.expect.Proxy, p.Proxy)
}
})
}
}

View File

@@ -1,221 +0,0 @@
//nolint:goconst
package types
import (
"strconv"
"strings"
"github.com/docker/docker/api/types"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/net/http/accesslog"
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
"github.com/yusing/go-proxy/internal/route/rules"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/internal/utils/strutils"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type (
RawEntry struct {
_ U.NoCopy
// raw entry object before validation
// loaded from docker labels or yaml file
Alias string `json:"alias"`
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
Port string `json:"port,omitempty"`
HTTPConfig
PathPatterns []string `json:"path_patterns,omitempty"`
Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"`
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
LoadBalance *loadbalance.Config `json:"load_balance,omitempty"`
Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"`
Homepage *homepage.Item `json:"homepage,omitempty"`
AccessLog *accesslog.Config `json:"access_log,omitempty"`
/* Docker only */
Container *docker.Container `json:"container,omitempty"`
Provider string `json:"provider,omitempty"`
finalized bool
}
RawEntries = F.Map[string, *RawEntry]
)
var NewProxyEntries = F.NewMapOf[string, *RawEntry]
func (e *RawEntry) Finalize() {
if e.finalized {
return
}
isDocker := e.Container != nil
cont := e.Container
if !isDocker {
cont = docker.DummyContainer
}
if e.Host == "" {
switch {
case cont.PrivateIP != "":
e.Host = cont.PrivateIP
case cont.PublicIP != "":
e.Host = cont.PublicIP
case !isDocker:
e.Host = "localhost"
}
}
lp, pp, extra := e.splitPorts()
if port, ok := common.ServiceNamePortMapTCP[cont.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
if e.Scheme == "" {
e.Scheme = "tcp"
}
} else if port, ok := common.ImageNamePortMap[cont.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
if e.Scheme == "" {
e.Scheme = "http"
}
} else if pp == "" && e.Scheme == "https" {
pp = "443"
} else if pp == "" {
if p := lowestPort(cont.PrivatePortMapping); p != "" {
pp = p
} else if p := lowestPort(cont.PublicPortMapping); p != "" {
pp = p
} else if !isDocker {
pp = "80"
} else {
logging.Debug().Msg("no port found for " + e.Alias)
}
}
// replace private port with public port if using public IP.
if e.Host == cont.PublicIP {
if p, ok := cont.PrivatePortMapping[pp]; ok {
pp = strutils.PortString(p.PublicPort)
}
}
// replace public port with private port if using private IP.
if e.Host == cont.PrivateIP {
if p, ok := cont.PublicPortMapping[pp]; ok {
pp = strutils.PortString(p.PrivatePort)
}
}
if e.Scheme == "" && isDocker {
switch {
case e.Host == cont.PublicIP && cont.PublicPortMapping[pp].Type == "udp":
e.Scheme = "udp"
case e.Host == cont.PrivateIP && cont.PrivatePortMapping[pp].Type == "udp":
e.Scheme = "udp"
}
}
if e.Scheme == "" {
switch {
case lp != "":
e.Scheme = "tcp"
case strings.HasSuffix(pp, "443"):
e.Scheme = "https"
default: // assume its http
e.Scheme = "http"
}
}
if e.HealthCheck == nil {
e.HealthCheck = new(health.HealthCheckConfig)
}
if e.HealthCheck.Disable {
e.HealthCheck = nil
} else {
if e.HealthCheck.Interval == 0 {
e.HealthCheck.Interval = common.HealthCheckIntervalDefault
}
if e.HealthCheck.Timeout == 0 {
e.HealthCheck.Timeout = common.HealthCheckTimeoutDefault
}
}
if cont.IdleTimeout != "" {
if cont.WakeTimeout == "" {
cont.WakeTimeout = common.WakeTimeoutDefault
}
if cont.StopTimeout == "" {
cont.StopTimeout = common.StopTimeoutDefault
}
if cont.StopMethod == "" {
cont.StopMethod = common.StopMethodDefault
}
}
e.Port = joinPorts(lp, pp, extra)
if e.Port == "" || e.Host == "" {
if lp != "" {
e.Port = lp + ":0"
} else {
e.Port = "0"
}
}
if e.Homepage.IsEmpty() {
e.Homepage = homepage.NewItem(e.Alias)
}
e.finalized = true
}
func (e *RawEntry) splitPorts() (lp string, pp string, extra string) {
portSplit := strutils.SplitRune(e.Port, ':')
if len(portSplit) == 1 {
pp = portSplit[0]
} else {
lp = portSplit[0]
pp = portSplit[1]
if len(portSplit) > 2 {
extra = strutils.JoinRune(portSplit[2:], ':')
}
}
return
}
func joinPorts(lp string, pp string, extra string) string {
s := make([]string, 0, 3)
if lp != "" {
s = append(s, lp)
}
if pp != "" {
s = append(s, pp)
}
if extra != "" {
s = append(s, extra)
}
return strutils.JoinRune(s, ':')
}
func lowestPort(ports map[string]types.Port) string {
var cmp uint16
var res string
for port, v := range ports {
if v.PrivatePort < cmp || cmp == 0 {
cmp = v.PrivatePort
res = port
}
}
return res
}

View File

@@ -3,14 +3,39 @@ package types
import (
"net/http"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/homepage"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/task"
"github.com/yusing/go-proxy/internal/watcher/health"
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
)
type (
//nolint:interfacebloat // this is for avoiding circular imports
Route interface {
Entry
task.TaskStarter
task.TaskFinisher
ProviderName() string
TargetName() string
TargetURL() *net.URL
HealthMonitor() health.HealthMonitor
Started() bool
IdlewatcherConfig() *idlewatcher.Config
HealthCheckConfig() *health.HealthCheckConfig
LoadBalanceConfig() *loadbalance.Config
HomepageConfig() *homepage.Item
ContainerInfo() *docker.Container
IsDocker() bool
UseLoadBalance() bool
UseIdleWatcher() bool
UseHealthCheck() bool
UseAccessLog() bool
}
HTTPRoute interface {
Route

View File

@@ -3,6 +3,6 @@ package types
type RouteType string
const (
RouteTypeStream RouteType = "stream"
RouteTypeReverseProxy RouteType = "reverse_proxy"
RouteTypeStream RouteType = "stream"
RouteTypeHTTP RouteType = "http"
)

View File

@@ -8,16 +8,22 @@ type Scheme string
var ErrInvalidScheme = E.New("invalid scheme")
func NewScheme(s string) (Scheme, error) {
const (
SchemeHTTP Scheme = "http"
SchemeHTTPS Scheme = "https"
SchemeTCP Scheme = "tcp"
SchemeUDP Scheme = "udp"
SchemeFileServer Scheme = "fileserver"
)
func (s Scheme) Validate() E.Error {
switch s {
case "http", "https", "tcp", "udp":
return Scheme(s), nil
case SchemeHTTP, SchemeHTTPS,
SchemeTCP, SchemeUDP, SchemeFileServer:
return nil
}
return "", ErrInvalidScheme.Subject(s)
return ErrInvalidScheme.Subject(string(s))
}
func (s Scheme) IsHTTP() bool { return s == "http" }
func (s Scheme) IsHTTPS() bool { return s == "https" }
func (s Scheme) IsTCP() bool { return s == "tcp" }
func (s Scheme) IsUDP() bool { return s == "udp" }
func (s Scheme) IsStream() bool { return s.IsTCP() || s.IsUDP() }
func (s Scheme) IsReverseProxy() bool { return s == SchemeHTTP || s == SchemeHTTPS }
func (s Scheme) IsStream() bool { return s == SchemeTCP || s == SchemeUDP }

View File

@@ -1,34 +0,0 @@
package types
import (
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type StreamPort struct {
ListeningPort Port `json:"listening"`
ProxyPort Port `json:"proxy"`
}
var ErrStreamPortTooManyColons = E.New("too many colons")
func ValidateStreamPort(p string) (StreamPort, error) {
split := strutils.SplitRune(p, ':')
switch len(split) {
case 1:
split = []string{"0", split[0]}
case 2:
break
default:
return StreamPort{}, ErrStreamPortTooManyColons.Subject(p)
}
listeningPort, lErr := ValidatePort(split[0])
proxyPort, pErr := ValidatePort(split[1])
if err := E.Join(lErr, pErr); err != nil {
return StreamPort{}, err
}
return StreamPort{listeningPort, proxyPort}, nil
}

View File

@@ -1,54 +0,0 @@
package types
import (
"strconv"
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
var validPorts = []string{
"1234:5678",
"0:2345",
"2345",
}
var invalidPorts = []string{
"",
"123:",
"0:",
":1234",
"qwerty",
"asdfgh:asdfgh",
"1234:asdfgh",
}
var outOfRangePorts = []string{
"-1:1234",
"1234:-1",
"65536",
"0:65536",
}
var tooManyColonsPorts = []string{
"1234:1234:1234",
}
func TestStreamPort(t *testing.T) {
for _, port := range validPorts {
_, err := ValidateStreamPort(port)
ExpectNoError(t, err)
}
for _, port := range invalidPorts {
_, err := ValidateStreamPort(port)
ExpectError2(t, port, strconv.ErrSyntax, err)
}
for _, port := range outOfRangePorts {
_, err := ValidateStreamPort(port)
ExpectError2(t, port, ErrPortOutOfRange, err)
}
for _, port := range tooManyColonsPorts {
_, err := ValidateStreamPort(port)
ExpectError2(t, port, ErrStreamPortTooManyColons, err)
}
}

View File

@@ -1,42 +0,0 @@
package types
import (
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
type StreamScheme struct {
ListeningScheme Scheme `json:"listening"`
ProxyScheme Scheme `json:"proxy"`
}
func ValidateStreamScheme(s string) (*StreamScheme, error) {
ss := &StreamScheme{}
parts := strutils.SplitRune(s, ':')
if len(parts) == 1 {
parts = []string{s, s}
} else if len(parts) != 2 {
return nil, ErrInvalidScheme.Subject(s)
}
var lErr, pErr error
ss.ListeningScheme, lErr = NewScheme(parts[0])
ss.ProxyScheme, pErr = NewScheme(parts[1])
if err := E.Join(lErr, pErr); err != nil {
return nil, err
}
return ss, nil
}
func (s StreamScheme) String() string {
return string(s.ListeningScheme) + " -> " + string(s.ProxyScheme)
}
// IsCoherent checks if the ListeningScheme and ProxyScheme of the StreamScheme are equal.
//
// It returns a boolean value indicating whether the ListeningScheme and ProxyScheme are equal.
func (s StreamScheme) IsCoherent() bool {
return s.ListeningScheme == s.ProxyScheme
}

View File

@@ -1,37 +0,0 @@
package types
import (
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
var (
validStreamSchemes = []string{
"tcp:tcp",
"tcp:udp",
"udp:tcp",
"udp:udp",
"tcp",
"udp",
}
invalidStreamSchemes = []string{
"tcp:tcp:",
"tcp:",
":udp:",
":udp",
"top",
}
)
func TestNewStreamScheme(t *testing.T) {
for _, s := range validStreamSchemes {
_, err := ValidateStreamScheme(s)
ExpectNoError(t, err)
}
for _, s := range invalidStreamSchemes {
_, err := ValidateStreamScheme(s)
ExpectError(t, ErrInvalidScheme, err)
}
}