diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..449ea57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.code-workspace +proxperfect +todo.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b1b14a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog of proxperfect + +## v1.0.1 (June 26, 2022) +* Initial release by Sven Breuner. + +### Contributors +* Thanks to Paul Hargreaves for helpful comments and suggestions. + diff --git a/Dockerfile b/Dockerfile new file mode 120000 index 0000000..394173d --- /dev/null +++ b/Dockerfile @@ -0,0 +1 @@ +build_helpers/docker/Dockerfile.alpine \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6232b6e --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +EXE ?= ./proxperfect + +all: $(EXE) + +$(EXE): $(EXE).go + go build -o $(EXE) $(EXE).go + +clean: + rm -f $(EXE) + +.PHONY: clean diff --git a/README.md b/README.md index b1bbbe5..59c8615 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # proxperfect -A fan-out reverse proxy for HTTP (including S3) + +proxperfect logo + +**A fan-out reverse proxy for HTTP (including S3)** + +proxperfect proxides a single endpoint for HTTP clients to connect to and forwards incoming requests to a given set of servers in a round-robin fashion. This is a way to balance load across multiple servers for applications that have no native support for multiple endpoints and for cases in which DNS-based load balancing is not feasible. + +## Usage + +The built-in help (`proxperfect --help`) provides simple examples to get started. + +You can get proxperfect pre-built for Linux from the [Releases section](https://github.com/breuner/proxperfect/releases) and from [Docker Hub](https://hub.docker.com/r/breuner/proxperfect). + +### Questions & Comments + +In case of questions, comments, if something is missing to make proxperfect more useful or if you would just like to share your thoughts, feel free to contact me: sven.breuner[at]gmail.com + diff --git a/build_helpers/docker/Dockerfile.alpine b/build_helpers/docker/Dockerfile.alpine new file mode 100644 index 0000000..77a29f6 --- /dev/null +++ b/build_helpers/docker/Dockerfile.alpine @@ -0,0 +1,26 @@ +# Proxperfect main executable from local source code on Alpine Linux for minimum image size. +# +# Run docker build from proxperfect repository root dir like this: +# docker build -t proxperfect-local -f build_helpers/docker/Dockerfile.alpine.local . + +FROM alpine:3 as builder + +RUN apk add bash go make + +RUN cd /root && \ + git clone https://github.com/breuner/proxperfect.git && \ + cd proxperfect && \ + go mod download && \ + make clean && \ + make + +FROM alpine:3 + +COPY --from=builder /root/proxperfect/proxperfect /usr/local/bin/ + +RUN apk add bash && \ + ln -s /usr/local/bin/proxperfect /usr/bin/proxperfect && \ + /usr/local/bin/proxperfect --version + +ENTRYPOINT ["/usr/local/bin/proxperfect"] + diff --git a/build_helpers/docker/Dockerfile.alpine.local b/build_helpers/docker/Dockerfile.alpine.local new file mode 100644 index 0000000..548e7da --- /dev/null +++ b/build_helpers/docker/Dockerfile.alpine.local @@ -0,0 +1,26 @@ +# Proxperfect main executable from local source code on Alpine Linux for minimum image size. +# +# Run docker build from proxperfect repository root dir like this: +# docker build -t proxperfect-local -f build_helpers/docker/Dockerfile.alpine.local . + +FROM alpine:3 as builder + +RUN apk add bash go make + +COPY ./ /root/proxperfect + +RUN cd /root/proxperfect && \ + go mod download && \ + make clean && \ + make + +FROM alpine:3 + +COPY --from=builder /root/proxperfect/proxperfect /usr/local/bin/ + +RUN apk add bash && \ + ln -s /usr/local/bin/proxperfect /usr/bin/proxperfect && \ + /usr/local/bin/proxperfect --version + +ENTRYPOINT ["/usr/local/bin/proxperfect"] + diff --git a/build_helpers/docker/README.md b/build_helpers/docker/README.md new file mode 100644 index 0000000..182b3a9 --- /dev/null +++ b/build_helpers/docker/README.md @@ -0,0 +1,8 @@ +# proxperfect + +proxperfect logo + +**A fan-out reverse proxy for HTTP (including S3)** + +For the full description, see proxperfect's readme and changelog on github: https://github.com/breuner/proxperfect + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2cb3881 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/breuner/proxperfect + +go 1.18 + +require golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff51700 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8= +golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/graphics/proxperfect-logo.svg b/graphics/proxperfect-logo.svg new file mode 100755 index 0000000..65ede26 --- /dev/null +++ b/graphics/proxperfect-logo.svg @@ -0,0 +1 @@ +properfect \ No newline at end of file diff --git a/proxperfect.go b/proxperfect.go new file mode 100644 index 0000000..de2479a --- /dev/null +++ b/proxperfect.go @@ -0,0 +1,284 @@ +// A reverse proxy that distributes load by forwarding different requests to different http servers + +package main + +import ( + "context" + "flag" + "fmt" + "golang.org/x/sync/semaphore" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strconv" + "sync" + "sync/atomic" + "syscall" +) + +const ProgName = "ProxPerfect" +const ProgVersion string = "1.0.1" + +type Config struct { + beVerbose bool + showVersion bool + listenPort int + proxyStrings []string + poolBufSize int + numConnsPerServer int // 0 disables this limit + redirectCode int // 0 disables redirect + fdLimit uint64 // 0 disables attempt to change +} + +var config Config + +type ProxyState struct { + proxies []*httputil.ReverseProxy + connLimiters []*semaphore.Weighted // per-proxy limit + requestNum uint32 +} + +var proxyState ProxyState + +// proxyBufferPool is a httputil.BufferPool backed by a thread-safe sync.Pool +// note: sync.Pool is garbage-collected on mem pressure, so doesn't need upper bound of elems +type proxyBufferPool struct { + pool *sync.Pool + bufAllocNum uint32 +} + +func NewProxyBufferPool() httputil.BufferPool { + return &proxyBufferPool{ + pool: new(sync.Pool), + bufAllocNum: 0, + } +} + +func (bufPool *proxyBufferPool) Get() []byte { + buf := bufPool.pool.Get() + if buf == nil { + if config.beVerbose { + var currentAllocNum = atomic.AddUint32(&bufPool.bufAllocNum, 1) + + fmt.Printf("Allocating proxy pool buf. Num: %d; Total alloc size: %d\n", currentAllocNum, uint32(config.poolBufSize)*currentAllocNum) + } + + return make([]byte, config.poolBufSize) + } + + return buf.([]byte) +} + +func (bufPool *proxyBufferPool) Put(buf []byte) { + bufPool.pool.Put(buf) +} + +func Usage() { + exename := filepath.Base(os.Args[0]) + + fmt.Printf("proxperfect - A fan-out reverse proxy") + + fmt.Printf("Usage: ./%s [OPTIONS] HTTP_SERVERS...\n", exename) + fmt.Println() + + fmt.Println("Options:") + flag.PrintDefaults() + fmt.Println() + + fmt.Printf("Example:\n") + fmt.Printf(" Forward requests round-robin to servers 192.168.0.1 through 192.168.0.8:\n") + fmt.Printf(" $ ./%s http://192.168.0.{1..8}\n", exename) +} + +// parse command line args and init config struct +func ParseArguments() { + showVersionConfigPtr := flag.Bool("version", false, "Print version and exit.") + beVerboseConfigPtr := flag.Bool("verbose", false, "Print verbose output.") + listenPortConfigPtr := flag.Int("port", 8080, "Port to listen on for incoming connections.") + poolBufSizeConfigPtr := flag.Int("bufsize", 128*1024, "Size of each pooled buffer in bytes. [0 disables buffer pooling.]") + numConnsPerServer := flag.Int("maxconns", 10, "Max number of connections per server. [0 disables limit.]") + redirectCode := flag.Int("redirect", 0, "Redirect requests using given HTTP code instead of proxying. [0 disables redirect; 301 is temporary redirect.]") + fdLimit := flag.Uint64("fdlimit", 0, "Increase open file descriptor limit of process (as in 'ulimit -n').") + + flag.Parse() + + config.beVerbose = *beVerboseConfigPtr + config.showVersion = *showVersionConfigPtr + config.listenPort = *listenPortConfigPtr + config.poolBufSize = *poolBufSizeConfigPtr + config.proxyStrings = flag.Args() + config.numConnsPerServer = *numConnsPerServer + config.redirectCode = *redirectCode + config.fdLimit = *fdLimit + + if config.showVersion { + fmt.Printf("%s v%s\n", ProgName, ProgVersion) + os.Exit(0) + } + + if config.beVerbose { + fmt.Println("HTTP Servers:", flag.Args()) + } + + if len(flag.Args()) == 0 { + fmt.Println("ERROR: HTTP servers missing. Specify one or more server arguments.") + fmt.Println(" (Format: \"http://:\")") + fmt.Println() + Usage() + + os.Exit(1) + } + + if config.fdLimit != 0 { + SetOpenFilesLimit() + } + + if config.beVerbose { + rlimit := GetOpenFilesLimit() + fmt.Printf("Current open files limit: %d (Max: %d)\n", rlimit.Cur, rlimit.Max) + } + +} + +// get "ulimit -n" +func GetOpenFilesLimit() syscall.Rlimit { + rlimit := syscall.Rlimit{Cur: 0, Max: 0} + + err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlimit) + + if err != nil { + fmt.Println("Unable to get limit for open file handles:", err) + } + + return rlimit +} + +// set "ulimit -n" +// this func will only increase the limit, not decrease +func SetOpenFilesLimit() { + rlimit := GetOpenFilesLimit() + + if config.fdLimit >= rlimit.Max { + fmt.Printf("Adjusting config for open files limit: Was exceeding max. (Current: %d; Max: %d; Config: %d)\n", rlimit.Cur, rlimit.Max, config.fdLimit) + config.fdLimit = rlimit.Max + } + + if rlimit.Cur >= config.fdLimit { + fmt.Printf("Skipping increase of open files limit: Config value is not higher than current value (Current: %d; Config: %d)\n", rlimit.Cur, config.fdLimit) + return + } + + rlimit.Cur = config.fdLimit + + err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rlimit) + if err != nil { + fmt.Println("Setting open files limit failed:", err) + } +} + +// NewProxy takes target host and creates a reverse proxy +func NewProxy(targetHost string) (*httputil.ReverseProxy, error) { + url, err := url.Parse(targetHost) + if err != nil { + return nil, err + } + + return httputil.NewSingleHostReverseProxy(url), nil +} + +func InitProxyState() { + proxyState.requestNum = 0 + + for i, proxyStr := range flag.Args() { + if config.beVerbose { + fmt.Printf("Adding proxy. Index: %d; Server: %s\n", i, proxyStr) + } + + proxy, err := NewProxy(flag.Arg(i)) + if err != nil { + panic(err) + } + + proxy.FlushInterval = -1 // negative value means "flush immediately" + + if config.poolBufSize > 0 { + proxy.BufferPool = NewProxyBufferPool() + } + + proxyState.proxies = append(proxyState.proxies, proxy) + + if config.numConnsPerServer != 0 { + var sem = semaphore.NewWeighted(int64(config.numConnsPerServer)) + proxyState.connLimiters = append(proxyState.connLimiters, sem) + } + } + +} + +// ProxyRequestHandler proxies the http request to server from given list +func ProxyRequestHandler() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var currentRequestNum = atomic.AddUint32(&proxyState.requestNum, 1) + var proxyIdx = currentRequestNum % uint32(len(proxyState.proxies)) + var proxy = proxyState.proxies[proxyIdx] + var limiter *semaphore.Weighted + + // limit concurrent connections for this proxy + if config.numConnsPerServer != 0 { + limiter = proxyState.connLimiters[proxyIdx] + ctx := context.Background() + limiter.Acquire(ctx, 1) + } + + if config.beVerbose { + fmt.Printf("[%s START #%d]: %s %s\n", config.proxyStrings[proxyIdx], currentRequestNum, r.Method, r.URL.String()) + } + + proxy.ServeHTTP(w, r) + + if config.beVerbose { + fmt.Printf("[%s END #%d]: %s %s\n", config.proxyStrings[proxyIdx], currentRequestNum, r.Method, r.URL.String()) + } + + if config.numConnsPerServer != 0 { + limiter.Release(1) + } + } +} + +// RedirectHandler redirects incoming http request to server from given list +func RedirectHandler(w http.ResponseWriter, r *http.Request) { + var currentRequestNum = atomic.AddUint32(&proxyState.requestNum, 1) + var proxyIdx = currentRequestNum % uint32(len(proxyState.proxies)) + var serverStr = config.proxyStrings[proxyIdx] + r.URL.String() + + if config.beVerbose { + fmt.Printf("[%s REDIRECT #%d]: %s %s\n", config.proxyStrings[proxyIdx], currentRequestNum, r.Method, r.URL.String()) + } + + http.Redirect(w, r, serverStr, config.redirectCode) +} + +func main() { + flag.Usage = Usage + + ParseArguments() + + InitProxyState() + + // register http request handler + if config.redirectCode == 0 { + // handle requests through proxy + http.HandleFunc("/", ProxyRequestHandler()) + } else { + // handle requests through redirector + http.HandleFunc("/", RedirectHandler) + } + + fmt.Printf("Listening on port %d...\n", config.listenPort) + + log.Fatal(http.ListenAndServe(":"+strconv.Itoa(config.listenPort), nil)) +}