initial commit

This commit is contained in:
Zoe
2025-05-15 15:41:10 +00:00
commit 2e7e6586ca
5 changed files with 268 additions and 0 deletions

24
LICENSE Normal file
View File

@@ -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 <https://unlicense.org>

65
README.md Normal file
View File

@@ -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.

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/juls0730/sentinel
go 1.24.2
require golang.org/x/sync v0.14.0

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=

172
main.go Normal file
View File

@@ -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
}