commit 2e7e6586cacd11c1192c44fd114f7d398e5f2de8 Author: Zoe <62722391+juls0730@users.noreply.github.com> Date: Thu May 15 15:41:10 2025 +0000 initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..beb29f5 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Sentinel + +Sentinel is a highly-available reverse proxy that can be used to distribute traffic to multiple backends. It is designed to be simple to use and easy to configure. + +## Features + +- Simple to use +- Easy to configure +- Highly-available + +## Installation + +To install Sentinel, you can use the following command: + +``` +go get github.com/juls0730/sentinel +``` + +## Usage + +all you need is a structure that holds the proxyManager structure and a function that returns a transport for the proxyManager + +```go +package main + +import ( + "github.com/juls0730/sentinel" + "net/http" +) + +func main() { + proxyManager := sentinel.NewProxyManager() + + proxyManager.ListenAndServe("localhost:8080") + + proxy, err := NewDeploymentProxy(socketPath) + if err != nil { + return err + } + + proxyManager.AddProxy("text.local", proxy) +} + +type unixDialer struct { + socketPath string +} + +// dialContext implements DialContext but ignored everthing and just gives you a connection to the unix socket +func (d *unixDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial("unix", d.socketPath) +} + +func getTransport(target string) *http.Transport { + return &http.Transport{ + DialContext: (&unixDialer{socketPath: socket}).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + MaxIdleConnsPerHost: 100, + ForceAttemptHTTP2: false, + } + +} +``` + +If you want more indepth examples of how to use Sentinel, you can check out [gloom](https://github.com/juls0730/gloom) which is a plugin-based web server that uses Sentinel to distribute traffic to multiple backends, or [Flux](https://github.com/juls0730/flux) which is a mini-paas that uses Sentinel to distribute traffic to project containers. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a6b4947 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/juls0730/sentinel + +go 1.24.2 + +require golang.org/x/sync v0.14.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a868a8e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5ce8408 --- /dev/null +++ b/main.go @@ -0,0 +1,172 @@ +package sentinel + +import ( + "context" + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "sync/atomic" + "time" + + "golang.org/x/sync/syncmap" +) + +type RequestLogger interface { + // LogRequest is called after an HTTP request has been processed by the proxy. + // It provides details about the request and its outcome. + LogRequest(host string, status int, latency time.Duration, ip, method, path string) +} + +// this is the object that oversees the proxying of requests to the correct deployment +type ProxyManager struct { + // string -> *Proxy + syncmap.Map + requestLogger RequestLogger +} + +func NewProxyManager(RequestLogger RequestLogger) *ProxyManager { + return &ProxyManager{ + Map: syncmap.Map{}, + requestLogger: RequestLogger, + } +} + +func (proxyManager *ProxyManager) ListenAndServe(host string) error { + if err := http.ListenAndServe(host, proxyManager); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("failed to start proxy server: %v", err) + } + return nil +} + +// Stops forwarding traffic to a deployment +func (proxyManager *ProxyManager) RemoveDeployment(host string) { + proxyManager.Delete(host) +} + +// Starts forwarding traffic to a deployment. The deployment must be ready to recieve requests before this is called. +func (proxyManager *ProxyManager) AddProxy(host string, proxy *Proxy) { + proxyManager.Store(host, proxy) +} + +// This function is responsible for taking an http request and forwarding it to the correct deployment +func (proxyManager *ProxyManager) ServeHTTP(w http.ResponseWriter, r *http.Request) { + start := time.Now() + host := r.Host + path := r.URL.Path + method := r.Method + ip := getClientIP(r) + + proxy, ok := proxyManager.Load(host) + if !ok { + http.Error(w, "Not found", http.StatusNotFound) + if proxyManager.requestLogger != nil { + proxyManager.requestLogger.LogRequest(host, http.StatusNotFound, time.Since(start), ip, method, path) + } + return + } + + // Create a custom ResponseWriter to capture the status code + rw := &ResponseWriterInterceptor{ResponseWriter: w, statusCode: http.StatusOK} + + proxy.(*Proxy).proxyFunc.ServeHTTP(rw, r) + + latency := time.Since(start) + statusCode := rw.statusCode + + if proxyManager.requestLogger != nil { + proxyManager.requestLogger.LogRequest(host, statusCode, latency, ip, method, path) + } +} + +// getClientIP retrieves the client's IP address from the request. +// It handles cases where the IP might be forwarded by proxies. +func getClientIP(r *http.Request) string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + return forwarded + } + return r.RemoteAddr +} + +// ResponseWriterInterceptor is a custom http.ResponseWriter that captures the status code. +type ResponseWriterInterceptor struct { + http.ResponseWriter + statusCode int +} + +func (rw *ResponseWriterInterceptor) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +type Proxy struct { + target string + proxyFunc *httputil.ReverseProxy + shutdownTimeout time.Duration + activeRequests int64 +} + +// TODO: make this configurable? +const PROXY_SHUTDOWN_TIMEOUT = 30 * time.Second + +// Creates a proxy for a given deployment +func NewDeploymentProxy(target string, transportFunc func(string) *http.Transport) (*Proxy, error) { + proxy := &Proxy{ + target: target, + shutdownTimeout: PROXY_SHUTDOWN_TIMEOUT, + activeRequests: 0, + } + + transport := transportFunc(target) + + proxy.proxyFunc = &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL = &url.URL{ + Scheme: "http", + Host: req.Host, + Path: req.URL.Path, + } + atomic.AddInt64(&proxy.activeRequests, 1) + }, + Transport: transport, + ModifyResponse: func(resp *http.Response) error { + atomic.AddInt64(&proxy.activeRequests, -1) + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + atomic.AddInt64(&proxy.activeRequests, -1) + w.WriteHeader(http.StatusInternalServerError) + }, + } + + return proxy, nil +} + +// waits for the proxy to be drained of connections within the shutdown timeout, then calls the shutdownFunc (the proxy should be removes or replaced in the ProxyMaager) +func (p *Proxy) GracefulShutdown(shutdownFunc func()) error { + ctx, cancel := context.WithTimeout(context.Background(), p.shutdownTimeout) + defer cancel() + + for { + select { + case <-ctx.Done(): + if shutdownFunc != nil { + shutdownFunc() + } + + return fmt.Errorf("proxy shutdown timed out for %s", p.target) + default: + } + if atomic.LoadInt64(&p.activeRequests) == 0 { + break + } + + time.Sleep(time.Second) + } + + if shutdownFunc != nil { + shutdownFunc() + } + + return nil +}