mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-31 06:03:06 +02:00
restructured the project to comply community guideline, for others check release note
This commit is contained in:
57
internal/api/handler.go
Normal file
57
internal/api/handler.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/error_page"
|
||||
. "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
)
|
||||
|
||||
type ServeMux struct{ *http.ServeMux }
|
||||
|
||||
func NewServeMux() ServeMux {
|
||||
return ServeMux{http.NewServeMux()}
|
||||
}
|
||||
|
||||
func (mux ServeMux) HandleFunc(method, endpoint string, handler http.HandlerFunc) {
|
||||
mux.ServeMux.HandleFunc(fmt.Sprintf("%s %s", method, endpoint), checkHost(handler))
|
||||
}
|
||||
|
||||
func NewHandler(cfg *config.Config) http.Handler {
|
||||
mux := NewServeMux()
|
||||
mux.HandleFunc("GET", "/v1", v1.Index)
|
||||
mux.HandleFunc("GET", "/v1/checkhealth", wrap(cfg, v1.CheckHealth))
|
||||
mux.HandleFunc("HEAD", "/v1/checkhealth", wrap(cfg, v1.CheckHealth))
|
||||
mux.HandleFunc("POST", "/v1/reload", wrap(cfg, v1.Reload))
|
||||
mux.HandleFunc("GET", "/v1/list", wrap(cfg, v1.List))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}", wrap(cfg, v1.List))
|
||||
mux.HandleFunc("GET", "/v1/file", v1.GetFileContent)
|
||||
mux.HandleFunc("GET", "/v1/file/{filename}", v1.GetFileContent)
|
||||
mux.HandleFunc("POST", "/v1/file/{filename}", v1.SetFileContent)
|
||||
mux.HandleFunc("PUT", "/v1/file/{filename}", v1.SetFileContent)
|
||||
mux.HandleFunc("GET", "/v1/stats", wrap(cfg, v1.Stats))
|
||||
mux.HandleFunc("GET", "/v1/error_page", error_page.GetHandleFunc())
|
||||
return mux
|
||||
}
|
||||
|
||||
// allow only requests to API server with host matching common.APIHTTPAddr
|
||||
func checkHost(f http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Host != common.APIHTTPAddr {
|
||||
Logger.Warnf("invalid request to API server with host: %s, expected: %s", r.Host, common.APIHTTPAddr)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
f(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func wrap(cfg *config.Config, f func(cfg *config.Config, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
f(cfg, w, r)
|
||||
}
|
||||
}
|
||||
42
internal/api/v1/checkhealth.go
Normal file
42
internal/api/v1/checkhealth.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
R "github.com/yusing/go-proxy/internal/route"
|
||||
)
|
||||
|
||||
func CheckHealth(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
target := r.FormValue("target")
|
||||
if target == "" {
|
||||
U.HandleErr(w, r, U.ErrMissingKey("target"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var ok bool
|
||||
route := cfg.FindRoute(target)
|
||||
|
||||
switch {
|
||||
case route == nil:
|
||||
U.HandleErr(w, r, U.ErrNotFound("target", target), http.StatusNotFound)
|
||||
return
|
||||
case route.Type() == R.RouteTypeReverseProxy:
|
||||
ok = U.IsSiteHealthy(route.URL().String())
|
||||
case route.Type() == R.RouteTypeStream:
|
||||
entry := route.Entry()
|
||||
ok = U.IsStreamHealthy(
|
||||
strings.Split(entry.Scheme, ":")[1], // target scheme
|
||||
fmt.Sprintf("%s:%v", entry.Host, strings.Split(entry.Port, ":")[1]),
|
||||
)
|
||||
}
|
||||
|
||||
if ok {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusRequestTimeout)
|
||||
}
|
||||
}
|
||||
88
internal/api/v1/error_page/error_page.go
Normal file
88
internal/api/v1/error_page/error_page.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package error_page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
|
||||
api "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||
W "github.com/yusing/go-proxy/internal/watcher"
|
||||
"github.com/yusing/go-proxy/internal/watcher/events"
|
||||
)
|
||||
|
||||
const errPagesBasePath = common.ErrorPagesBasePath
|
||||
|
||||
var setup = sync.OnceFunc(func() {
|
||||
dirWatcher = W.NewDirectoryWatcher(context.Background(), errPagesBasePath)
|
||||
loadContent()
|
||||
go watchDir()
|
||||
})
|
||||
|
||||
func GetStaticFile(filename string) ([]byte, bool) {
|
||||
return fileContentMap.Load(filename)
|
||||
}
|
||||
|
||||
// try <statusCode>.html -> 404.html -> not ok
|
||||
func GetErrorPageByStatus(statusCode int) (content []byte, ok bool) {
|
||||
content, ok = fileContentMap.Load(fmt.Sprintf("%d.html", statusCode))
|
||||
if !ok && statusCode != 404 {
|
||||
return fileContentMap.Load("404.html")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func loadContent() {
|
||||
files, err := U.ListFiles(errPagesBasePath, 0)
|
||||
if err != nil {
|
||||
api.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
for _, file := range files {
|
||||
if fileContentMap.Has(file) {
|
||||
continue
|
||||
}
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
api.Logger.Errorf("failed to read error page resource %s: %s", file, err)
|
||||
continue
|
||||
}
|
||||
file = path.Base(file)
|
||||
api.Logger.Infof("error page resource %s loaded", file)
|
||||
fileContentMap.Store(file, content)
|
||||
}
|
||||
}
|
||||
|
||||
func watchDir() {
|
||||
eventCh, errCh := dirWatcher.Events(context.Background())
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-eventCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filename := event.ActorName
|
||||
switch event.Action {
|
||||
case events.ActionFileWritten:
|
||||
fileContentMap.Delete(filename)
|
||||
loadContent()
|
||||
case events.ActionFileDeleted:
|
||||
fileContentMap.Delete(filename)
|
||||
api.Logger.Infof("error page resource %s deleted", filename)
|
||||
case events.ActionFileRenamed:
|
||||
api.Logger.Infof("error page resource %s deleted", filename)
|
||||
fileContentMap.Delete(filename)
|
||||
loadContent()
|
||||
}
|
||||
case err := <-errCh:
|
||||
api.Logger.Errorf("error watching error page directory: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dirWatcher W.Watcher
|
||||
var fileContentMap = F.NewMapOf[string, []byte]()
|
||||
25
internal/api/v1/error_page/http_handler.go
Normal file
25
internal/api/v1/error_page/http_handler.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package error_page
|
||||
|
||||
import "net/http"
|
||||
|
||||
func GetHandleFunc() http.HandlerFunc {
|
||||
setup()
|
||||
return serveHTTP
|
||||
}
|
||||
|
||||
func serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/" {
|
||||
http.Error(w, "invalid path", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
content, ok := fileContentMap.Load(r.URL.Path)
|
||||
if !ok {
|
||||
http.Error(w, "404 not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Write(content)
|
||||
}
|
||||
59
internal/api/v1/file.go
Normal file
59
internal/api/v1/file.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/proxy/provider"
|
||||
)
|
||||
|
||||
func GetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" {
|
||||
filename = common.ConfigFileName
|
||||
}
|
||||
content, err := os.ReadFile(path.Join(common.ConfigBasePath, filename))
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Write(content)
|
||||
}
|
||||
|
||||
func SetFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.PathValue("filename")
|
||||
if filename == "" {
|
||||
U.HandleErr(w, r, U.ErrMissingKey("filename"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var validateErr E.NestedError
|
||||
if filename == common.ConfigFileName {
|
||||
validateErr = config.Validate(content)
|
||||
} else {
|
||||
validateErr = provider.Validate(content)
|
||||
}
|
||||
|
||||
if validateErr != nil {
|
||||
U.RespondJson(w, validateErr.JSONObject(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = os.WriteFile(path.Join(common.ConfigBasePath, filename), content, 0644)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
7
internal/api/v1/index.go
Normal file
7
internal/api/v1/index.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package v1
|
||||
|
||||
import "net/http"
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("API ready"))
|
||||
}
|
||||
61
internal/api/v1/list.go
Normal file
61
internal/api/v1/list.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
)
|
||||
|
||||
func List(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
what := r.PathValue("what")
|
||||
if what == "" {
|
||||
what = "routes"
|
||||
}
|
||||
|
||||
switch what {
|
||||
case "routes":
|
||||
listRoutes(cfg, w, r)
|
||||
case "config_files":
|
||||
listConfigFiles(w, r)
|
||||
default:
|
||||
U.HandleErr(w, r, U.ErrInvalidKey("what"), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func listRoutes(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
routes := cfg.RoutesByAlias()
|
||||
typeFilter := r.FormValue("type")
|
||||
if typeFilter != "" {
|
||||
for k, v := range routes {
|
||||
if v["type"] != typeFilter {
|
||||
delete(routes, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := U.RespondJson(w, routes); err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func listConfigFiles(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := os.ReadDir(common.ConfigBasePath)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
filenames := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
filenames[i] = f.Name()
|
||||
}
|
||||
resp, err := json.Marshal(filenames)
|
||||
if err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Write(resp)
|
||||
}
|
||||
16
internal/api/v1/reload.go
Normal file
16
internal/api/v1/reload.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
)
|
||||
|
||||
func Reload(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
if err := cfg.Reload(); err != nil {
|
||||
U.RespondJson(w, err.JSONObject(), http.StatusInternalServerError)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
20
internal/api/v1/stats.go
Normal file
20
internal/api/v1/stats.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
"github.com/yusing/go-proxy/internal/server"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
func Stats(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
stats := map[string]any{
|
||||
"proxies": cfg.Statistics(),
|
||||
"uptime": utils.FormatDuration(server.GetProxyServer().Uptime()),
|
||||
}
|
||||
if err := U.RespondJson(w, stats); err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
}
|
||||
}
|
||||
34
internal/api/v1/utils/error.go
Normal file
34
internal/api/v1/utils/error.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
)
|
||||
|
||||
var Logger = logrus.WithField("module", "api")
|
||||
|
||||
func HandleErr(w http.ResponseWriter, r *http.Request, origErr error, code ...int) {
|
||||
err := E.From(origErr).Subjectf("%s %s", r.Method, r.URL)
|
||||
Logger.Error(err)
|
||||
if len(code) > 0 {
|
||||
http.Error(w, err.String(), code[0])
|
||||
return
|
||||
}
|
||||
http.Error(w, err.String(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func ErrMissingKey(k string) error {
|
||||
return errors.New("missing key '" + k + "' in query or request body")
|
||||
}
|
||||
|
||||
func ErrInvalidKey(k string) error {
|
||||
return errors.New("invalid key '" + k + "' in query or request body")
|
||||
}
|
||||
|
||||
func ErrNotFound(k, v string) error {
|
||||
return fmt.Errorf("key %q with value %q not found", k, v)
|
||||
}
|
||||
33
internal/api/v1/utils/health_check.go
Normal file
33
internal/api/v1/utils/health_check.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
)
|
||||
|
||||
func IsSiteHealthy(url string) bool {
|
||||
// try HEAD first
|
||||
// if HEAD is not allowed, try GET
|
||||
resp, err := httpClient.Head(url)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err != nil && resp != nil && resp.StatusCode == http.StatusMethodNotAllowed {
|
||||
_, err = httpClient.Get(url)
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func IsStreamHealthy(scheme, address string) bool {
|
||||
conn, err := net.DialTimeout(scheme, address, common.DialTimeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
23
internal/api/v1/utils/http_client.go
Normal file
23
internal/api/v1/utils/http_client.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{
|
||||
Timeout: common.ConnectionTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DisableKeepAlives: true,
|
||||
ForceAttemptHTTP2: true,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: common.DialTimeout,
|
||||
KeepAlive: common.KeepAlive, // this is different from DisableKeepAlives
|
||||
}).DialContext,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
31
internal/api/v1/utils/localhost.go
Normal file
31
internal/api/v1/utils/localhost.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
)
|
||||
|
||||
func ReloadServer() E.NestedError {
|
||||
resp, err := httpClient.Post(fmt.Sprintf("%s/v1/reload", common.APIHTTPURL), "", nil)
|
||||
if err != nil {
|
||||
return E.From(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
failure := E.Failure("server reload").Extraf("status code: %v", resp.StatusCode)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return failure.Extraf("unable to read response body: %s", err)
|
||||
}
|
||||
reloadErr, ok := E.FromJSON(b)
|
||||
if ok {
|
||||
return E.Join("reload success, but server returned error", reloadErr)
|
||||
}
|
||||
return failure.Extraf("unable to read response body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
20
internal/api/v1/utils/utils.go
Normal file
20
internal/api/v1/utils/utils.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func RespondJson(w http.ResponseWriter, data any, code ...int) error {
|
||||
if len(code) > 0 {
|
||||
w.WriteHeader(code[0])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
j, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
} else {
|
||||
w.Write(j)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user